201 lines
9.1 KiB
Python
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 {}))
|