Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/evidence-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down
23 changes: 23 additions & 0 deletions docs/ai-review-role-contract.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion scripts/gate_evidence_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
29 changes: 28 additions & 1 deletion tests/test_evidence_gate_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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", [
Expand Down Expand Up @@ -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
Loading