code-to10 v6 A10: release gate single evaluator via wiring adapter
(cherry picked from commit 77ed1c3094798f75bc41323f9aa60f744f5ba507)
This commit is contained in:
1 parent
31edc5cda9
commit
e7acc143db
3 files changed
+126
-117
No files matched your search
+78
-81
@@ -1,38 +1,42 @@
|
||||
"""Future release endpoint: the gate check, without the endpoint.
|
||||
"""Release gate, unified: thin adapter over the single evaluator.
|
||||
|
||||
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
|
||||
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 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``
|
||||
- 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 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.
|
||||
- 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 future endpoint
|
||||
must call this evaluator at release time, with fresh evidence,
|
||||
even if the task was merged minutes ago.
|
||||
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.
|
||||
@@ -101,9 +105,9 @@ 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
|
||||
from cloud.audit_loop_wiring import evaluate_release_gate as _wiring_evaluate
|
||||
|
||||
# Machine codes (kept as module constants so the future endpoint
|
||||
# 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"
|
||||
@@ -129,75 +133,68 @@ def _decision(allowed, code, blocked_by, sources, blocks=None,
|
||||
|
||||
|
||||
def evaluate_release(task_id, conn, hard_gate=None, task_status=None):
|
||||
"""Decide whether a task may be released, right now.
|
||||
"""Thin compatibility adapter over the single evaluator.
|
||||
|
||||
``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).
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
# 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.
|
||||
effective_status = "merged" if task_status is None else task_status
|
||||
try:
|
||||
pushback = pushback_block_for_task(conn, task_id)
|
||||
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 = []
|
||||
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"):
|
||||
if isinstance(hard_gate, dict):
|
||||
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)
|
||||
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 {}))
|
||||
Reference in new issue
Block a user