diff --git a/.github/workflows/evidence-gate.yml b/.github/workflows/evidence-gate.yml index 5573c87..bec2dcc 100644 --- a/.github/workflows/evidence-gate.yml +++ b/.github/workflows/evidence-gate.yml @@ -10,6 +10,8 @@ on: - "src/**/combo_manifests.py" - "docs/evidence/**" - "evidence/**" + - "scripts/gate_evidence_package.py" + - ".github/workflows/evidence-gate.yml" permissions: contents: read @@ -74,6 +76,7 @@ jobs: - name: Evaluate Evidence Gate env: GITHUB_BASE_REF: ${{ github.base_ref }} + AI_SERVICE_RELEASE_READY: ${{ vars.AI_SERVICE_RELEASE_READY }} AI_SERVICE_URL: ${{ vars.AI_SERVICE_URL }} AI_SERVICE_AUDIENCE: ${{ vars.AI_SERVICE_AUDIENCE }} AI_SERVICE_REVIEWERS_JSON: ${{ vars.AI_SERVICE_REVIEWERS_JSON }} diff --git a/docs/ai-review-role-contract.md b/docs/ai-review-role-contract.md new file mode 100644 index 0000000..12628ce --- /dev/null +++ b/docs/ai-review-role-contract.md @@ -0,0 +1,23 @@ +# AI review role contract + +Drift review and the PR Evidence Gate use the same three role labels: +`primary`, `secondary`, and `verification`. These must match the labels in +`AI_SERVICE_REVIEWERS_JSON`; the old `reviewer-primary`, `reviewer-secondary`, +and `reviewer-independent` labels were incompatible with the configured routes. + +Evidence promotion now requires `AI_SERVICE_RELEASE_READY=true` before any +model dispatch. Missing, false, or invalid readiness fails closed with exit 3; +`DUAL_REVIEW_GATE_SKIP` cannot bypass readiness. PRs without a status promotion +still pass the deterministic no-promotion check. Draft PRs do not dispatch the +Evidence Gate. The reusable drift workflow already gates on the same readiness +variable. No repository variables, grants, IAM, or service configuration change +is part of this patch. + +On 2026-10-15, the repository variables were read back as `RELEASE_READY=false` +and all three review roles use `mode=agent`, `model=configured-dot`. Their +profile labels differ, but all routes currently go to Dot. Three labels or +three task IDs do not prove provider/model independence. The `verification` +role therefore does **not** satisfy an independent reviewer requirement. +Readiness must remain false until routing, admission, and actual independent +review evidence have been verified by the owner. Renaming roles does not +establish independence or authorize adoption, deployment, or trading. diff --git a/scripts/gate_evidence_package.py b/scripts/gate_evidence_package.py index 6da4295..a51d257 100755 --- a/scripts/gate_evidence_package.py +++ b/scripts/gate_evidence_package.py @@ -97,6 +97,9 @@ def _validate_with_promotion_standard(path: Path) -> tuple[bool, list[str]]: def _run_promotion_dual_review(evidence_files: list[Path]) -> int: """Advisory V2 task-service quorum; any unavailable or pending result fails closed.""" + if os.environ.get("AI_SERVICE_RELEASE_READY", "").strip().lower() != "true": + print("[evidence-gate] AI service release is not ready; promotion blocked", file=sys.stderr) + return 3 if str(os.environ.get("DUAL_REVIEW_GATE_SKIP", "")).strip().lower() in {"1", "true", "yes"}: print("[evidence-gate] dual-review skipped by DUAL_REVIEW_GATE_SKIP") return 0 @@ -118,7 +121,7 @@ def _run_promotion_dual_review(evidence_files: list[Path]) -> int: raise ValueError("caller revision required") result = review_material( material, operation_id="crypto-evidence:" + caller_ref + ":" + path.as_posix(), - required_roles=("reviewer-primary", "reviewer-secondary", "reviewer-independent"), + required_roles=("primary", "secondary", "verification"), ) except Exception: print("[evidence-gate] review task unavailable", file=sys.stderr) diff --git a/tests/test_evidence_gate_review.py b/tests/test_evidence_gate_review.py index 535da2a..1a32784 100644 --- a/tests/test_evidence_gate_review.py +++ b/tests/test_evidence_gate_review.py @@ -34,6 +34,7 @@ def review_material(material, *, operation_id, required_roles): @pytest.fixture def evidence(tmp_path, monkeypatch): monkeypatch.setenv("GITHUB_SHA", "a" * 40) + monkeypatch.setenv("AI_SERVICE_RELEASE_READY", "true") monkeypatch.delenv("DUAL_REVIEW_GATE_SKIP", raising=False) path = tmp_path / "synthetic.json" path.write_text(json.dumps({"strategy_profile": "synthetic"}), encoding="utf-8") @@ -43,7 +44,8 @@ def evidence(tmp_path, monkeypatch): def test_unanimous_advisory_approval_passes(monkeypatch, evidence): calls = _install_reviewer(monkeypatch, {"status": "completed", "outcome": "agree_approve", "advisory_only": True}) assert _load_gate()._run_promotion_dual_review([evidence]) == 0 - assert len(calls) == 1 and len(set(calls[0][1])) == 3 + assert len(calls) == 1 + assert calls[0][1] == ("primary", "secondary", "verification") @pytest.mark.parametrize("result,code", [ @@ -71,3 +73,28 @@ def test_workflow_uses_v2_task_service_only(): assert retired not in workflow assert "AI_SERVICE_URL: ${{ vars.AI_SERVICE_URL }}" in workflow assert "id-token: write" in workflow + + +@pytest.mark.parametrize("ready", [None, "", "false", "1", "yes", "invalid"]) +@pytest.mark.parametrize("skip", ["", "true"]) +def test_release_not_ready_blocks_before_dispatch(monkeypatch, evidence, ready, skip): + calls = _install_reviewer(monkeypatch, {"status": "completed", "outcome": "agree_approve", "advisory_only": True}) + if ready is None: + monkeypatch.delenv("AI_SERVICE_RELEASE_READY", raising=False) + else: + monkeypatch.setenv("AI_SERVICE_RELEASE_READY", ready) + monkeypatch.setenv("DUAL_REVIEW_GATE_SKIP", skip) + assert _load_gate()._run_promotion_dual_review([evidence]) == 3 + assert calls == [] + + +def test_nonpromotion_diff_needs_no_ai_release(monkeypatch): + gate = _load_gate() + monkeypatch.setattr(gate, "_git_diff", lambda _: "+# documentation only") + monkeypatch.delenv("AI_SERVICE_RELEASE_READY", raising=False) + assert gate.main() == 0 + + +def test_workflow_passes_release_readiness(): + workflow = (ROOT / ".github" / "workflows" / "evidence-gate.yml").read_text(encoding="utf-8") + assert "AI_SERVICE_RELEASE_READY: ${{ vars.AI_SERVICE_RELEASE_READY }}" in workflow