diff --git a/examples/canary/premerge-validation-gate-smoke.py b/examples/canary/premerge-validation-gate-smoke.py index d8d01a10ba..91deb91e0e 100644 --- a/examples/canary/premerge-validation-gate-smoke.py +++ b/examples/canary/premerge-validation-gate-smoke.py @@ -18,6 +18,7 @@ from loopx.canary.premerge import ( # noqa: E402 _gate_status, _public_boundary_changed_files_run, + apply_change_quality_verification, build_premerge_validation_gate, downgrade_inherited_baseline_failures, ) @@ -309,6 +310,45 @@ def assert_no_changes_does_not_mask_direct_failures() -> None: assert status["merge_gate_passed"] is False, status +def assert_passed_validation_does_not_grant_self_merge_authority() -> None: + status = _gate_status( + execute=True, + changed_files=["docs/example.md"], + direct_checks=[{"ok": True, "status": "passed", "id": "diff_check"}], + catalog_run={"ok": True}, + risk_profile_run=None, + boundary_run=None, + manual_holds=[], + ) + assert status["status"] == "passed", status + assert status["merge_gate_passed"] is True, status + assert status["self_merge_validation_passed"] is True, status + assert status["self_merge_allowed"] is False, status + assert status["self_merge_authority"] == { + "granted": False, + "reason": "repository_policy_required", + "next_gate": "apply repository policy and exact-head merge readiness", + }, status + + payload = { + "ok": True, + "gate": status, + "validation_summary": {"failure_count": 0}, + "recommended_pr_comment_fields": [], + } + apply_change_quality_verification( + payload, + { + "enforcement_applied": True, + "ok": False, + "status": "receipt_invalid", + }, + ) + assert payload["gate"]["merge_gate_passed"] is False, payload + assert payload["gate"]["self_merge_validation_passed"] is False, payload + assert payload["gate"]["self_merge_allowed"] is False, payload + + def assert_installed_wrapper_uses_bound_python_and_redirects_to_checkout() -> None: with tempfile.TemporaryDirectory() as temp_dir: release_root = Path(temp_dir) / "release" @@ -527,6 +567,7 @@ def main() -> None: assert_cli_json_preview() assert_cli_premerge_reports_progress_by_default() assert_no_changes_does_not_mask_direct_failures() + assert_passed_validation_does_not_grant_self_merge_authority() assert_installed_wrapper_uses_bound_python_and_redirects_to_checkout() assert_external_dirty_worktree_uses_caller_repo() assert_inherited_maintainability_red_is_advisory_only() diff --git a/loopx/canary/premerge.py b/loopx/canary/premerge.py index d69ca2928d..8889e88267 100644 --- a/loopx/canary/premerge.py +++ b/loopx/canary/premerge.py @@ -600,10 +600,20 @@ def _gate_status( status = "preview_only" else: status = "passed" + self_merge_validation_passed = status == "passed" and not manual_holds return { "status": status, "merge_gate_passed": status == "passed", - "self_merge_allowed": status == "passed" and not manual_holds, + # This gate owns validation, not repository merge authority. Keep the + # historical field fail-closed so a green canary cannot be mistaken for + # permission to bypass a repository's independent-maintainer policy. + "self_merge_allowed": False, + "self_merge_validation_passed": self_merge_validation_passed, + "self_merge_authority": { + "granted": False, + "reason": "repository_policy_required", + "next_gate": "apply repository policy and exact-head merge readiness", + }, "direct_failure_count": len(direct_failures), "run_failure_count": len(run_failures), "manual_hold_count": len(manual_holds), @@ -722,6 +732,7 @@ def apply_change_quality_verification( "status": f"quality_{status}", "merge_gate_passed": False, "self_merge_allowed": False, + "self_merge_validation_passed": False, } ) summary["failure_count"] = int(summary.get("failure_count") or 0) + 1 @@ -1028,8 +1039,9 @@ def build_premerge_validation_gate( "Pre-merge validation is a risk-based gate: it runs diff hygiene, " "changed Python compile checks, catalog-selected canaries, risk-profile " "smokes, and public/private boundary checks. It reports manual holds for " - "benchmark-sensitive or reviewer-gated surfaces instead of treating local " - "smoke success as self-merge permission." + "benchmark-sensitive or reviewer-gated surfaces. Passing validation is " + "not merge authority: repository policy and exact-head merge readiness " + "remain separate mandatory gates." ), } if progress_callback and execute: @@ -1095,6 +1107,10 @@ def render_premerge_validation_gate_markdown(payload: dict[str, Any]) -> str: f"- ok: `{str(payload.get('ok')).lower()}`", f"- merge_gate_passed: `{str(gate.get('merge_gate_passed')).lower()}`", f"- self_merge_allowed: `{str(gate.get('self_merge_allowed')).lower()}`", + f"- self_merge_validation_passed: " + f"`{str(gate.get('self_merge_validation_passed')).lower()}`", + f"- self_merge_authority: " + f"`{str((gate.get('self_merge_authority') or {}).get('reason') or '')}`", f"- tier: `{payload.get('tier')}`", f"- dry_run: `{str(payload.get('dry_run')).lower()}`", f"- changed_files: `{classification.get('changed_file_count')}`",