204 lines
9.1 KiB
Python
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)
|