release: gate-check contract for future release endpoint + ledger anchor ops (prep, no endpoint, no deploy)

This commit is contained in:
host committed 2026-11-03 02:49:26 +00:00
1 parent 765cff29ff
commit cdb481092d
5 files changed
+1288

No files matched your search

+203
View File
@@ -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)
+152
View File
@@ -0,0 +1,152 @@
"""Anchor script: exit-code contract for tools/ledger_anchor_check.py.
Runs the script as a subprocess against throwaway SQLite chains
in a TemporaryDirectory (deleted with the test). Pins only what
the operator depends on: 0 pass, 1 verification failure with the
failure named, 2 usage error, and --write-anchor refused on a
failing chain. The script itself never writes the chain; the
tamper/tail cases simulate privileged tampering by dropping the
SQLite append-only triggers first (a normal UPDATE is already
blocked by the trigger, which is itself asserted here).
"""
import json
import os
import sqlite3
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT = REPO_ROOT / "tools" / "ledger_anchor_check.py"
try:
from cloud.audit_wiring import record_audit_verdict
from cloud.ledger import LedgerStore
except ImportError:
sys.path.insert(0, str(REPO_ROOT))
from cloud.audit_wiring import record_audit_verdict
from cloud.ledger import LedgerStore
def _run(*args):
proc = subprocess.run(
[sys.executable, str(SCRIPT), *args],
capture_output=True, text=True, timeout=60)
try:
report = json.loads(proc.stdout) if proc.stdout.strip() else None
except ValueError:
report = None
return proc.returncode, report, proc.stderr
class LedgerAnchorCheckTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
store = LedgerStore(self.db_path)
record_audit_verdict(store, "t1", "p1", "apps/web", "v2",
"fail", "audit", failures=["ci_green"])
record_audit_verdict(store, "t1", "p1", "apps/web", "v2",
"pass", "audit")
head = store.export()["head_hash"]
store.close()
self.anchor_path = os.path.join(self.tmp.name, "anchor.json")
with open(self.anchor_path, "w", encoding="utf-8") as handle:
json.dump({"version": 1, "head_hash": head, "count": 2},
handle)
def tearDown(self):
self.tmp.cleanup()
def _copy_db(self, name):
import shutil
path = os.path.join(self.tmp.name, name)
shutil.copyfile(self.db_path, path)
return path
def test_pass_without_and_with_anchor(self):
code, report, _ = _run("--db", self.db_path)
self.assertEqual(code, 0)
self.assertTrue(report["ok"])
code, report, _ = _run("--db", self.db_path,
"--anchor", self.anchor_path)
self.assertEqual(code, 0)
self.assertTrue(report["ok"])
self.assertEqual(report["head_advanced_by"], 0)
def test_normal_update_blocked_by_trigger_then_privileged_tamper_fails(self):
path = self._copy_db("tamper.db")
conn = sqlite3.connect(path)
with self.assertRaises(sqlite3.IntegrityError):
conn.execute(
"UPDATE audit_ledger SET detail='x' WHERE id=1")
conn.rollback()
conn.execute("DROP TRIGGER audit_ledger_no_update")
conn.execute("UPDATE audit_ledger SET detail='tampered'"
" WHERE id=1")
conn.commit()
conn.close()
code, report, _ = _run("--db", path,
"--anchor", self.anchor_path)
self.assertEqual(code, 1)
self.assertFalse(report["ok"])
self.assertTrue(any("entry_hash mismatch" in f
for f in report["failures"]))
def test_tail_deletion_fails_against_old_anchor(self):
path = self._copy_db("tail.db")
conn = sqlite3.connect(path)
conn.execute("DROP TRIGGER audit_ledger_no_delete")
conn.execute("DELETE FROM audit_ledger WHERE id=2")
conn.commit()
conn.close()
code, report, _ = _run("--db", path,
"--anchor", self.anchor_path)
self.assertEqual(code, 1)
self.assertFalse(report["ok"])
self.assertTrue(any("shorter than anchor" in f
for f in report["failures"]))
def test_extended_chain_still_passes_anchor(self):
store = LedgerStore(self.db_path)
record_audit_verdict(store, "t1", "p1", "apps/web", "v2",
"pass", "audit")
store.close()
code, report, _ = _run("--db", self.db_path,
"--anchor", self.anchor_path)
self.assertEqual(code, 0)
self.assertTrue(report["ok"])
self.assertEqual(report["head_advanced_by"], 1)
def test_write_anchor_refused_on_failing_export(self):
path = self._copy_db("tail2.db")
conn = sqlite3.connect(path)
conn.execute("DROP TRIGGER audit_ledger_no_delete")
conn.execute("DELETE FROM audit_ledger WHERE id=2")
conn.commit()
conn.close()
store = LedgerStore(path)
export_path = os.path.join(self.tmp.name, "export.json")
with open(export_path, "w", encoding="utf-8") as handle:
json.dump(store.export(), handle)
store.close()
out_path = os.path.join(self.tmp.name, "new-anchor.json")
code, _report, _ = _run("--export", export_path,
"--anchor", self.anchor_path,
"--write-anchor", out_path)
self.assertEqual(code, 1)
self.assertFalse(os.path.exists(out_path))
def test_usage_errors_exit_2(self):
code, _report, _ = _run(
"--db", os.path.join(self.tmp.name, "nope.db"))
self.assertEqual(code, 2)
code, _report, _ = _run("--db", "postgres://host/db")
self.assertEqual(code, 2)
if __name__ == "__main__":
unittest.main()
+268
View File
@@ -0,0 +1,268 @@
"""Future release endpoint: contract tests for cloud/release_gate.
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
production flow; the chain lives in a TemporaryDirectory and is
deleted with the test.
What is pinned (and nowhere else):
- A blocked audit pushback blocks release (409, audit code),
even when the fresh hard gate passes.
- A passing re-audit unblocks release -- but only with a FRESH
passing hard gate supplied by the caller; a merge-time 200 is
never an input, and a missing hard gate blocks fail-closed.
- A chain-read failure blocks fail-closed (state unavailable),
distinguishable from an audit block and from a hard-gate block.
- A hard-gate failure blocks release even when the chain is
clear, and both sources are listed when both block.
- CORRECTED-to-pass does NOT unblock: a meta-review correction
to ``pass`` with no new passing conclusion stays blocked; only
a fresh passing verdict unblocks (the #247/#244 semantic,
re-checked at release time through the same reader).
- A fix alone does not unblock.
- A merge-time pass is not reused: a chain that was clear at
"merge time" and is blocked by the time release is evaluated
blocks release (the evaluator re-reads the chain every call).
- The evaluator is read-only: evaluating never moves the chain
head (no row is appended by a check).
"""
import os
import sqlite3
import sys
import tempfile
import unittest
from pathlib import Path
try:
from cloud.audit_pushback import GATE_CHECKS # noqa: F401
from cloud.audit_wiring import pushback_package_from_gate
from cloud.audit_wiring import record_audit_verdict
from cloud.audit_wiring import record_fix
from cloud.audit_wiring import record_meta_review
from cloud.audit_wiring import record_pushback
from cloud.audit_wiring import record_reaudit
from cloud.ledger import LedgerStore
from cloud.merge_gate_hard import evaluate_hard_merge_gate
from cloud.release_gate import evaluate_release
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.audit_pushback import GATE_CHECKS # noqa: F401
from cloud.audit_wiring import pushback_package_from_gate
from cloud.audit_wiring import record_audit_verdict
from cloud.audit_wiring import record_fix
from cloud.audit_wiring import record_meta_review
from cloud.audit_wiring import record_pushback
from cloud.audit_wiring import record_reaudit
from cloud.ledger import LedgerStore
from cloud.merge_gate_hard import evaluate_hard_merge_gate
from cloud.release_gate import evaluate_release
MODULE = "apps/web"
CONTRACT = "v2"
def _passing_gate():
return evaluate_hard_merge_gate(
files_changed=["apps/web/a.py"], owned_dir="apps/web",
protection="protected", ci_status="success",
ci_checks=[{"name": "ci", "status": "success"}],
required_checks=["ci"], contract_version="v2",
contract_frozen=True, coordinator_approved=True,
unresolved_review_comments=0)
def _failing_gate():
return evaluate_hard_merge_gate(
files_changed=["apps/web/a.py"], owned_dir="apps/web",
protection="protected", ci_status="pending",
ci_checks=[{"name": "ci", "status": "pending"}],
required_checks=["ci"], contract_version="v2",
contract_frozen=True, coordinator_approved=True,
unresolved_review_comments=0)
class ReleaseGateTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.store = LedgerStore(self.db_path)
self.task = "task-1"
self.project = "proj-1"
def tearDown(self):
try:
self.store.close()
except Exception:
pass
self.tmp.cleanup()
def _conn(self):
return sqlite3.connect(self.db_path)
def _decide(self, hard_gate=None, task_status="merged"):
conn = self._conn()
try:
return evaluate_release(
self.task, conn, hard_gate=hard_gate,
task_status=task_status)
finally:
conn.close()
def _block_task(self):
record_audit_verdict(
self.store, self.task, self.project, MODULE, CONTRACT,
"fail", "audit", failures=["ci_green"])
package = pushback_package_from_gate(
_failing_gate(), self.task, 0)
record_pushback(self.store, self.task, self.project, MODULE,
CONTRACT, 1, package["failed_checks"], package,
failure_source="audit")
return package
def _head(self):
return self.store.verify()["head_hash"]
def test_audit_pushback_blocks_release_even_with_passing_gate(self):
self._block_task()
decision = self._decide(hard_gate=_passing_gate())
self.assertFalse(decision["allowed"])
self.assertEqual(decision["http_status"], 409)
self.assertEqual(decision["code"],
"release_blocked_audit_pushback")
self.assertEqual(decision["blocked_by"], "audit_pushback")
self.assertIn("release_blocked_audit_pushback",
decision["blocked_sources"])
self.assertIn("ci_green", decision["failed_checks"])
self.assertEqual(decision["round"], 1)
def test_reaudit_pass_allows_release_with_fresh_passing_gate(self):
package = self._block_task()
record_reaudit(self.store, self.task, self.project, MODULE,
CONTRACT, 1, {"verdict": "pass"},
package=package)
decision = self._decide(hard_gate=_passing_gate())
self.assertTrue(decision["allowed"])
self.assertEqual(decision["http_status"], 200)
self.assertEqual(decision["code"], "release_allowed")
self.assertIsNone(decision["blocked_by"])
self.assertEqual(decision["blocked_sources"], [])
def test_corrected_to_pass_still_blocks_until_fresh_pass(self):
# One failing verdict is the ONLY block here, so the
# correction below is the only thing that could unblock
# -- if it did, this test would catch it. (A second fail
# or an open pushback would mask a wrong unblock.)
verdict = record_audit_verdict(
self.store, self.task, self.project, MODULE, CONTRACT,
"fail", "audit", failures=["ci_green"])
self.assertFalse(
self._decide(hard_gate=_passing_gate())["allowed"])
# Audit-of-audit correction: the original fail was wrong,
# corrected to pass. That schedules a re-audit; it does
# NOT release the gate by itself.
record_meta_review(
self.store, self.task, self.project, MODULE, CONTRACT,
verdict["id"], "fail", "pass", True, reviewer="auditor-b")
decision = self._decide(hard_gate=_passing_gate())
self.assertFalse(decision["allowed"])
self.assertEqual(decision["code"],
"release_blocked_audit_pushback")
# Only a fresh passing verdict unblocks.
record_audit_verdict(
self.store, self.task, self.project, MODULE, CONTRACT,
"pass", "audit")
decision = self._decide(hard_gate=_passing_gate())
self.assertTrue(decision["allowed"], decision)
def test_hard_gate_blocks_release_when_chain_is_clear(self):
record_audit_verdict(
self.store, self.task, self.project, MODULE, CONTRACT,
"pass", "audit")
decision = self._decide(hard_gate=_failing_gate())
self.assertFalse(decision["allowed"])
self.assertEqual(decision["http_status"], 409)
self.assertEqual(decision["code"], "release_blocked_hard_gate")
self.assertEqual(decision["blocked_by"], "hard_gate")
self.assertTrue(decision["blocks"])
self.assertNotIn("release_blocked_audit_pushback",
decision["blocked_sources"])
def test_both_sources_listed_when_both_block(self):
self._block_task()
decision = self._decide(hard_gate=_failing_gate())
self.assertFalse(decision["allowed"])
self.assertIn("release_blocked_hard_gate",
decision["blocked_sources"])
self.assertIn("release_blocked_audit_pushback",
decision["blocked_sources"])
self.assertTrue(decision["blocks"])
self.assertTrue(decision["failed_checks"])
def test_chain_read_failure_fails_closed(self):
conn = self._conn()
conn.close()
decision = evaluate_release(
self.task, conn, hard_gate=_passing_gate(),
task_status="merged")
self.assertFalse(decision["allowed"])
self.assertEqual(decision["http_status"], 409)
self.assertEqual(decision["code"],
"release_blocked_state_unavailable")
self.assertEqual(
decision["blocked_by"],
"audit_pushback_state_unavailable")
def test_missing_hard_gate_evidence_blocks(self):
record_audit_verdict(
self.store, self.task, self.project, MODULE, CONTRACT,
"pass", "audit")
decision = self._decide(hard_gate=None)
self.assertFalse(decision["allowed"])
self.assertEqual(decision["code"],
"release_hard_gate_evidence_missing")
def test_unmerged_task_cannot_release(self):
record_audit_verdict(
self.store, self.task, self.project, MODULE, CONTRACT,
"pass", "audit")
decision = self._decide(hard_gate=_passing_gate(),
task_status="completed")
self.assertFalse(decision["allowed"])
self.assertEqual(decision["code"], "release_requires_merged")
def test_merge_time_pass_is_not_reused_at_release(self):
# "Merge time": chain clear, fresh gate passes.
first = self._decide(hard_gate=_passing_gate())
self.assertTrue(first["allowed"], first)
# Between merge and release, a pushback lands on the chain.
self._block_task()
second = self._decide(hard_gate=_passing_gate())
self.assertFalse(second["allowed"])
self.assertEqual(second["code"],
"release_blocked_audit_pushback")
def test_fix_alone_does_not_unblock_release(self):
package = self._block_task()
record_fix(self.store, self.task, self.project, MODULE,
CONTRACT, 1,
{"summary": "fix ci", "diff_sha256": "b" * 64,
"files_changed": ["apps/web/a.py"]}, package)
decision = self._decide(hard_gate=_passing_gate())
self.assertFalse(decision["allowed"])
def test_evaluator_is_read_only(self):
self._block_task()
before = self._head()
self._decide(hard_gate=_passing_gate())
self._decide(hard_gate=_failing_gate())
self.assertEqual(self._head(), before)
self.assertTrue(self.store.verify()["ok"])
if __name__ == "__main__":
unittest.main()