"""Release gate, unified: thin adapter over the single evaluator. The sole release evaluator is ``cloud/audit_loop_wiring.evaluate_release_gate``. The live endpoint (``POST /tasks/{id}/release``, ``handler_lifecycle._release_task``) calls it directly; this module only adapts its decision into the legacy shape below so the contract tests in ``cloud/tests/test_release_gate.py`` keep pinning the same behaviour. There is no second evaluator in this tree: do not add evaluation logic here, change it in ``audit_loop_wiring`` and update this adapter's translation. ``deploy.py`` remains an operator script, not a release endpoint (see ``docs/release-gate-and-ledger-anchor.md``). What this module IS: - A pure, read-only adapter: ``evaluate_release`` delegates to the wiring evaluator, which re-reads the hash chain through the SAME reader the merge endpoint uses and ANDs it with a FRESH hard-gate evaluation supplied by the caller. It never appends to the chain, never calls ``AuthStore.audit`` (which writes a chain row in the same transaction), never commits, and never deploys anything. Checking must not move the chain head: otherwise the check itself would invalidate the external anchor taken before it. - The contract the live endpoint implements. The endpoint (``POST /tasks/{id}/release`` in ``handler_lifecycle``) calls the wiring evaluator directly and maps its decision to HTTP exactly as documented in the companion doc; this module only adapts that decision into the legacy shape for its contract tests, it is not a second evaluation path. What this module is NOT: - It is not a merge gate. Merge (``POST /tasks/{id}/merge``) and release are separate decisions with separate re-checks: a merge-time 200 is never a release pass. The live endpoint calls the wiring evaluator at release time, with fresh evidence, even if the task was merged minutes ago. - It is not a deploy gate. ``deploy.py`` deploys the platform itself (host release directory + unit restarts) and is deliberately outside this gate; see the companion doc. - It does not decide task status, auth, or publication. Status (``task_status``) and the hard-gate evidence (``hard_gate``) are caller-supplied inputs, so the evaluator can be tested without a server, a task row, or a publication step. When the caller omits an input, the evaluator fails closed on that input instead of assuming it (missing hard-gate evidence blocks; an unmerged status blocks when a status is given). Decision shape (``evaluate_release`` return value):: { "allowed": bool, # True only if every check passes "http_status": 200 | 409, # the status the endpoint maps to "code": str, # machine code, see below "blocked_by": str | None, # primary source, or None if allowed "blocked_sources": [str], # every applicable source, in order "blocks": [str], # hard-gate block codes, if any "failed_checks": [str], # audit-pushback failed checks, if any "round": int | None, # pushback round, if any "modules": dict, # per-module block detail, if any } Machine codes (``code`` / ``blocked_sources``): - ``release_allowed`` -- every check passed. - ``release_requires_merged`` -- a task status was supplied and it is not ``"merged"``. Release is a post-merge act; an unmerged task has nothing to release. - ``release_blocked_state_unavailable`` -- the chain could not be read at all (anything other than the reader's own missing-table rule, which means "no chain has ever been written" and is inherited from #247 unchanged). Fail-closed: an unreadable chain is never read as "no block". - ``release_blocked_hard_gate`` -- the fresh hard-gate evaluation (five checks: CI, directory, contract, coordinator, reviews, plus branch protection) did not allow. The failing codes travel in ``blocks`` verbatim, so the caller can tell a hard-gate block from an audit block without parsing text. - ``release_blocked_audit_pushback`` -- the re-read chain says a pushback is still open (``release_blocked`` from the same reader the merge endpoint uses). This is the #247 reader's verdict at release time, not a cached merge-time value. When several sources block at once, ``blocked_sources`` lists all of them (chain-unavailable dominates and short-circuits, because without a chain read no other verdict is trustworthy), while ``blocked_by``/``code`` name the first source in evaluation order: chain read, then task status, then hard gate, then audit pushback. The endpoint must return the full decision object, not just the first code, so an operator can see that fixing one source still leaves another blocking. CORRECTED-to-pass does NOT unblock (inherited, not invented): The reader already implements the #247/#244 resolved semantic: a meta-review correction to ``pass`` schedules a re-audit, it does not release the gate; only a fresh passing verdict or a passing re-reviewed pushback row unblocks. This evaluator adds no special case for corrections, so the release path cannot drift from the merge path: the same chain, read through the same reader, gives the same answer at both gates. The contract tests pin this: corrected-to-pass with no new passing conclusion stays blocked at release, exactly as at merge. """ from cloud.audit_loop_wiring import evaluate_release_gate as _wiring_evaluate # Machine codes (kept as module constants so the endpoint # and its tests spell them identically, never by string literal). CODE_ALLOWED = "release_allowed" CODE_REQUIRES_MERGED = "release_requires_merged" CODE_STATE_UNAVAILABLE = "release_blocked_state_unavailable" CODE_HARD_GATE = "release_blocked_hard_gate" CODE_AUDIT_PUSHBACK = "release_blocked_audit_pushback" CODE_HARD_GATE_EVIDENCE_MISSING = "release_hard_gate_evidence_missing" def _decision(allowed, code, blocked_by, sources, blocks=None, failed_checks=None, round_no=None, modules=None): return { "allowed": allowed, "http_status": 200 if allowed else 409, "code": code, "blocked_by": blocked_by, "blocked_sources": list(sources), "blocks": list(blocks or []), "failed_checks": list(failed_checks or []), "round": round_no, "modules": dict(modules or {}), } def evaluate_release(task_id, conn, hard_gate=None, task_status=None): """Thin compatibility adapter over the single evaluator. The sole implementation is ``cloud/audit_loop_wiring.evaluate_release_gate`` (used directly by the live endpoint ``handler_lifecycle._release_task``). This function only translates that decision into the legacy shape this module originally returned (``blocked_sources`` as codes, plus ``blocks`` / ``failed_checks`` / ``round`` / ``modules``), so the contract tests in ``test_release_gate.py`` keep pinning the same behaviour without a second evaluator existing anywhere in the tree. Legacy contract preserved verbatim: - ``task_status`` omitted skips the status check (the live endpoint must never omit it: it always passes the real status). Wiring blocks a ``None`` status, so the adapter passes ``"merged"`` as a placeholder ONLY in the omitted case; that placeholder is not a verdict, it is the skip. - ``hard_gate=None`` blocks with ``release_hard_gate_evidence_missing``; a merge-time 200 is never accepted as fresh evidence. - A failed chain read blocks fail-closed with ``release_blocked_state_unavailable``. - The adapter never writes: it delegates to the read-only wiring evaluator and only reshapes its return value. """ effective_status = "merged" if task_status is None else task_status try: decision = _wiring_evaluate( task_id, conn, task_status=effective_status, hard_gate=hard_gate) except Exception: return _decision( False, CODE_STATE_UNAVAILABLE, "audit_pushback_state_unavailable", [CODE_STATE_UNAVAILABLE]) if decision.get("code") == CODE_STATE_UNAVAILABLE: return _decision( False, CODE_STATE_UNAVAILABLE, "audit_pushback_state_unavailable", [CODE_STATE_UNAVAILABLE]) pushback = decision.get("audit_pushback") or {} sources = [] for source in decision.get("blocked_sources") or []: if source == "task_status": sources.append(CODE_REQUIRES_MERGED) elif source == "hard_gate": sources.append( CODE_HARD_GATE_EVIDENCE_MISSING if hard_gate is None else CODE_HARD_GATE) elif source == "audit_pushback": sources.append(CODE_AUDIT_PUSHBACK) blocks = [] if isinstance(hard_gate, dict): blocks = list(hard_gate.get("blocks") or []) if not sources: return _decision(True, CODE_ALLOWED, None, []) return _decision( False, decision.get("code"), decision.get("blocked_by"), sources, blocks=blocks, failed_checks=list(pushback.get("failed_checks") or []), round_no=pushback.get("round"), modules=dict(pushback.get("modules") or {}))