"""Future release endpoint: the gate check, without the endpoint. This module is the wiring preparation for the release endpoint that PR #247 explicitly left open: ``release_blocked`` is returned alongside the merge response today, but nothing enforces it yet, because there is no server-side release endpoint in this codebase and ``deploy.py`` is an operator script, not a release endpoint (see ``docs/release-gate-and-ledger-anchor.md``). What this module IS: - A pure, read-only evaluator: ``evaluate_release`` re-reads the hash chain through the SAME reader the merge endpoint uses (``cloud/audit_wiring.pushback_block_for_task``) 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 future endpoint must implement. The endpoint itself (route, HTTP plumbing, task-status transition, actual publication) is NOT built here: building a live endpoint in this track would be exactly the "new live release endpoint touching production" the task forbids. The endpoint, when it is approved and built, calls this evaluator and maps its decision to HTTP exactly as documented in the companion doc. 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 future endpoint must call this 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_wiring import pushback_block_for_task # Machine codes (kept as module constants so the future 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): """Decide whether a task may be released, right now. ``task_id`` is the task whose chain is re-read. ``conn`` is a DB connection the caller owns (the evaluator never closes it, never commits on it, never writes through it). ``hard_gate`` must be a FRESH evaluation of the hard merge gate (``cloud/merge_gate_hard.evaluate_hard_merge_gate``) against fresh evidence: ``{"allowed": bool, "blocks": [...]}``. Passing ``None`` blocks with ``release_hard_gate_evidence_missing`` -- a merge-time 200, a cached gate object, or "the task is already merged" is never accepted as fresh evidence. ``task_status``, when given, must be ``"merged"``; when omitted, the status check is skipped and the caller owns it (the future endpoint must not omit it). Order: chain read first (unavailable short-circuits, because every other verdict would be untrustworthy without it), then task status, then hard gate, then audit pushback. Hard-gate and pushback blocks are both collected, so a decision blocked by both says so instead of hiding one behind the other. """ # 1. Re-read the chain, now -- never a cached merge-time value. # The reader itself returns "allow" when the audit_ledger # table does not exist yet (no chain ever written, #247 rule, # inherited unchanged). Any other read failure propagates # out of the reader and is fail-closed here. try: pushback = pushback_block_for_task(conn, task_id) except Exception: return _decision( False, CODE_STATE_UNAVAILABLE, "audit_pushback_state_unavailable", [CODE_STATE_UNAVAILABLE]) sources = [] blocks = [] failed_checks = [] round_no = None modules = {} primary_code = None primary_by = None def _note(code, by): nonlocal primary_code, primary_by sources.append(code) if primary_code is None: primary_code = code primary_by = by # 2. Task status, when the caller supplies it. if task_status is not None and task_status != "merged": _note(CODE_REQUIRES_MERGED, "task_status") # 3. Fresh hard-gate evidence (five checks + protection). if hard_gate is None: _note(CODE_HARD_GATE_EVIDENCE_MISSING, "hard_gate") elif not hard_gate.get("allowed"): blocks = list(hard_gate.get("blocks") or []) _note(CODE_HARD_GATE, "hard_gate") # 4. Audit pushback, re-read above. if pushback.get("release_blocked"): failed_checks = list(pushback.get("failed_checks") or []) round_no = pushback.get("round") modules = dict(pushback.get("modules") or {}) _note(CODE_AUDIT_PUSHBACK, "audit_pushback") if not sources: return _decision(True, CODE_ALLOWED, None, []) return _decision(False, primary_code, primary_by, sources, blocks=blocks, failed_checks=failed_checks, round_no=round_no, modules=modules)