release: gate-check contract for future release endpoint + ledger anchor ops (prep, no endpoint, no deploy)
This commit is contained in:
1 parent
765cff29ff
commit
cdb481092d
5 files changed
+1288
No files matched your search
@@ -0,0 +1,203 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user