Files
codex-app-server/cloud/release_gate.py
host e7acc143db code-to10 v6 A10: release gate single evaluator via wiring adapter
(cherry picked from commit 77ed1c3094798f75bc41323f9aa60f744f5ba507)
2026-11-03 02:49:26 +00:00

201 lines
9.1 KiB
Python

"""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 {}))