Files
codex-app-server/cloud/release_gate.py
T

204 lines
9.1 KiB
Python

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