code-to10 v6 A10: release gate single evaluator via wiring adapter

(cherry picked from commit 77ed1c3094798f75bc41323f9aa60f744f5ba507)
This commit is contained in:
host committed 2026-11-04 21:03:32 +00:00
1 parent 3ef4411c32
commit fd22f03c15
3 files changed
+126 -117

No files matched your search

+78 -81
View File
@@ -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 {}))
+8 -6
View File
@@ -1,10 +1,12 @@
"""Future release endpoint: contract tests for cloud/release_gate.
"""Release gate: contract tests for cloud/release_gate (adapter).
These tests pin the CONTRACT, not an endpoint: there is no live
release endpoint in this codebase (building one here would be
the "new live endpoint touching production" this track forbids),
so every test calls ``evaluate_release`` directly with a local
SQLite chain. Nothing here deploys, publishes, or touches a
These tests pin the CONTRACT, not the endpoint: the live
endpoint (``POST /tasks/{id}/release``) calls the wiring
evaluator directly, and every test here calls the
``release_gate`` adapter ``evaluate_release`` directly with a
local SQLite chain to prove the adapter translates the single
wiring implementation into the legacy shape without a second
evaluator. Nothing here deploys, publishes, or touches a
production flow; the chain lives in a TemporaryDirectory and is
deleted with the test.