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-03 02:49:26 +00:00
1 parent 31edc5cda9
commit e7acc143db
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.
+40 -30
View File
@@ -1,13 +1,14 @@
# 发布端点接线准备 + 账本锚点运维流程
> 本轨性质:**接线准备,不是发布端点本身**。本轨不新建 live
> 发布端点、不碰生产、不跑 `deploy.py`、不合并、不部署。
> 发布端点是否立项、锚点存哪里、校验周期多长,均属用户侧
> 本轨性质:**接线准备 + 归一说明**。发布端点现已实现,见 §2;本轨不新建其它 live
> 发布端点;本轨不碰生产、不跑 `deploy.py`、不合并、不部署。
> 锚点存哪里、校验周期多长,均属用户侧
> 拍板项(见 §6),本文只给推荐值与判断依据,不替用户定。
>
> 基底:堆叠于 PR #247(`feat-audit-wiring`,`217a27f`),
> 合并顺序:#209 → #240 → #244 → #241 → #247 → 本轨。
> 本轨代码:`cloud/release_gate.py`(纯只读 evaluator)、
> 本轨代码:`cloud/audit_loop_wiring.py` 的 `evaluate_release_gate`(纯只读唯一实现)、
> `cloud/release_gate.py`(其薄转发适配器,保留旧契约形状)、
> `cloud/tests/test_release_gate.py`(11 项契约测试)、
> `tools/ledger_anchor_check.py`(只读校验脚本)、
> `cloud/tests/test_ledger_anchor_check.py`(脚本退出码测试)。
@@ -22,16 +23,16 @@
五项之间做 AND:任一阻塞即 409、状态不变,并在响应里附
`audit_pushback` 对象(`merge_blocked` / `release_blocked` /
`blocked_by="audit_pushback"` / `round` / `failed_checks`)。
- 但本仓库**没有独立的发布端点**:`release_blocked` 今天只是
随合并响应返回的一个字段,由“未来的发布端点用同一 reader
强制”。在那之前,合并放行后、发布前没有任何服务端检查
会再读一次链。
- 发布端点现已实现(`POST /tasks/{id}/release`,见 §2):
合并放行后、发布前,服务端按 §3 顺序重读一次链、重算
硬门禁、核任务状态;`release_blocked` 不再只是随合并响应
返回的字段。
- 账本(#241)已有导出头哈希,但**没有库外锚点存放与定期
校验的运维流程**:头哈希只在导出响应里出现一次,不存下来
就等于没有锚点;尾巴删除单靠库内校验发现不了(#241 已明
说),必须靠“旧锚点 vs 当前链”的比对。
## 2. 未来发布端点接口契约(建议,待立项后实现)
## 2. 发布端点接口契约(已实现,唯一实现为 wiring 版)
### 2.1 端点
@@ -39,14 +40,17 @@
|---|---|---|
| 方法 + 路径 | `POST /tasks/{id}/release` | 与 `POST /tasks/{id}/merge` 对仗;`{id}` 为任务 id,路径风格与现有任务端点一致 |
| 请求体 | `{}` 或 `{"confirm": true}`(二选一,立项时定) | 本轨不规定体字段;关键在服务端**不信任**体里的任何门禁结论,只信任自己重算的结果 |
| 成功 | `200` + 任务对象 + `release_gate` 决策对象 | 决策对象即 `evaluate_release` 的返回值原样返回,不另起一套字段 |
| 成功 | `200` + 任务对象 + `release_gate` 决策对象 | 决策对象即 wiring `evaluate_release_gate` 的返回值原样返回,不另起一套字段(`blocked_sources` 为源名,如 `task_status` / `hard_gate` / `audit_pushback`;`release_gate` 适配器的旧形状——`blocked_sources` 为码 + 顶层 `blocks` / `failed_checks` / `round` / `modules`——仅用于 `test_release_gate.py` 的契约测试,不经过端点) |
| 阻塞 | `409` + `{"error": "release blocked", "release_gate": {...}}` | 见 §2.4 的阻塞码;阻塞时**不改任务状态、不发布、不写链** |
| 任务不存在 | `404` | 与 merge 端点一致 |
| 无权 | `403` | 见 §2.2 |
本轨**没有实现这个端点**:`cloud/release_gate.py` 只有 evaluator,
没有路由、没有 HTTP plumbing、没有任务状态变更、没有发布动作。
契约测试直接测 evaluator(见 §4),不测一个不存在的端点。
发布端点**已经实现**:路由见 `handler_lifecycle._release_task`
(`POST /tasks/{id}/release`),唯一实现是
`audit_loop_wiring.evaluate_release_gate`;`cloud/release_gate.py`
只是把 wiring 决策译回旧契约形状的薄转发适配器,不再是
第二个 evaluator。契约测试直接测该适配器(见 §4),端点
行为另由 `test_server_audit_loop` 等端点测试覆盖。
### 2.2 鉴权:与 verify/export 同源、但为写动作
@@ -66,9 +70,9 @@
### 2.3 接线点:发布前必做的三件事(顺序即 §3 的求值顺序)
未来端点在执行任何发布动作之前,必须按顺序做:
发布端点在执行任何发布动作之前,必须按顺序做:
1. **重读链**:调 `evaluate_release`,它内部调
1. **重读链**:调 wiring 的 `evaluate_release_gate`(经由 `release_gate.evaluate_release` 适配器亦同),它内部调
`cloud/audit_wiring.pushback_block_for_task`(与 merge 端点
同一个 reader)。读到的必须是**发布时刻**的链状态。
2. **重算硬门禁**:用**发布时刻的新证据**重新跑
@@ -89,6 +93,12 @@
`blocked_by`(首要阻塞源)、`blocked_sources`(全部阻塞源),
端点必须原样返回整个对象,不能只返回首要码——否则修好
一个源后才发现另一个源仍阻塞,运维会被误导。
注意:端点返回的是 wiring 决策(`blocked_sources` 为源名,
硬门禁细节在 `hard_gate` 子对象里、审计细节在
`audit_pushback` 子对象里);下表“附加字段”列的顶层
`blocks` / `failed_checks` / `round` / `modules` 是
`release_gate` 适配器的旧契约形状,仅见于契约测试,不见
于端点响应。
| 阻塞源 | `code` | `blocked_by` | HTTP | 附加字段 | 修复路径 |
|---|---|---|---|---|---|
@@ -149,7 +159,7 @@ reader 返回“无推回状态”(链从未写过),不算读链失败。
用**唯一阻塞**构造钉住这一点:若纠正本身能解封,该测试
必失败(测试注释说明了为何不用“纠正 + 另一失败”的掩盖构造)。
## 3. evaluator 求值顺序(`cloud/release_gate.py`)
## 3. evaluator 求值顺序(唯一实现:`cloud/audit_loop_wiring.py`;`cloud/release_gate.py` 为薄转发适配器)
1. 读链。异常 → `release_blocked_state_unavailable`,短路
(没有可信链读数时,其它结论都不可信)。
@@ -168,9 +178,8 @@ reader 返回“无推回状态”(链从未写过),不算读链失败。
## 4. 契约测试(`cloud/tests/test_release_gate.py`,11 项)
测试只测 evaluator + 本地 SQLite 链(`TemporaryDirectory`,
跑完删除),**不部署、不调用生产发布流程**(生产本就没有
发布端点可调)。覆盖:
测试只测适配器 + 本地 SQLite 链(`TemporaryDirectory`,
跑完删除),**不部署、不调用生产发布流程**。覆盖:
| 测试 | 断言 |
|---|---|
@@ -238,7 +247,7 @@ reader 返回“无推回状态”(链从未写过),不算读链失败。
| 触发 | 动作 |
|---|---|
| 每日定时(cron/定时任务,运维账号) | 取最新锚点 → 全链校验 + 前缀比对;通过则取新锚点(轮换见 §5.4) |
| 每次发布前(未来发布端点立项后) | 发布流程先跑一次锚点校验;校验失败即发布 409(与 §2.4 的链读失败同级处理,属未来端点行为,本轨不实现) |
| 每次发布前 | 发布流程先跑一次锚点校验;校验失败即发布 409(与 §2.4 的链读失败同级处理,属运维流程接线,不在本轨代码内自动执行) |
| 每次合并门禁异常/人工介入后 | 运维手动跑一次,确认链未被介入改动 |
| 锚点轮换时 | 新锚点必须先通过旧锚点的前缀校验,才能成为“当前锚点”(见 §5.4) |
@@ -259,8 +268,8 @@ reader 返回“无推回状态”(链从未写过),不算读链失败。
任务日志与运维值班渠道留一条可查记录,不能只打印到
没人看的日志文件里)。
- **是否冻结**:校验失败期间,**发布立即冻结**(不发布;
未来发布端点立项后以校验失败作 409 条件之一;立项前由
运维人工冻结发布操作)。合并是否冻结由当值全局管理员
发布端点以校验失败作 409 条件之一(接线见上);校验流程
未跑时由运维人工冻结发布操作)。合并是否冻结由当值全局管理员
当场决定并记录——本轨不以代码自动冻结合并(那会是
新的门禁语义,须另行立项),但 runbook 要求管理员在
收到告警后 1 个工作日内给出“冻结合并与否”的书面决定,
@@ -357,7 +366,7 @@ Postgres URL)。输出为单 JSON 对象,便于定时任务解析。
它部署的是**平台本身**(服务端代码),不是某个任务的
合并产物;它没有任务 id、没有调用方提交的门禁证据、
不走服务端 HTTP 鉴权(走 SSH deploy key),与
`POST /tasks/{id}/merge` / 未来 `POST /tasks/{id}/release`
`POST /tasks/{id}/merge` / `POST /tasks/{id}/release`
根本不在同一层。
- **为什么不在门禁内**:门禁的客体是“任务能否合并/发布”,
deploy.py 的客体是“平台代码能否上线”。把 deploy.py 绑到
@@ -369,29 +378,30 @@ Postgres URL)。输出为单 JSON 对象,便于定时任务解析。
1. 部署完成后,运维手动跑一次 §5.2 的锚点校验(部署不
应改链;若校验失败,按 §5.3 处理,与部署成功与否分开判);
2. 部署说明里写明“部署 ≠ 任务发布”:推新平台版本不等于
发布任何任务,任务发布仍走未来发布端点。
发布任何任务,任务发布仍走 `POST /tasks/{id}/release`。
- 若未来有人提议“deploy.py 发布前查链”,须先回答:查哪
个任务的链?以谁的身份?回滚时查不查?答不上来就不纳入
——本轨已把这三个问题写进 PR 正文,供立项时复核。
## 7. 未做项与诚实边界(本轨明确不声称的)
- 未建发布端点:无路由、无 HTTP 测试、无任务状态变更、
无真实发布动作;`release_gate.py` 只是 evaluator + 契约。
- 发布端点已建(路由 + 端点测试在位),但无真实发布动作
之外的部署行为;`release_gate.py` 只是 wiring 的薄转发
适配器 + 契约,不再是独立 evaluator。
- 未部署、未跑 `deploy.py`、未动生产、主机 env、系统 key、
sub2api、生产网关、仓库可见性、GitHub App 权限、生产容器。
- 未在真实 PG 上验证:PG 触发器语法、PG 上的链校验与锚点
流程,均只以常量/导出模式写清,须生产 PG 手动执行并验证。
- 未替用户拍板:发布端点是否立项、锚点对象存储选型与
- 未替用户拍板:锚点对象存储选型与
账号、校验周期(推荐每日 + 每次发布前)、告警渠道、
锚点保留期下限、合并冻结与否的默认策略——见 §8。
- 未改 v1.1 契约、分支保护决策、已有 PR 分支与主 checkout。
## 8. 用户侧仍需拍板的点(本轨不擅自决定)
1. 发布端点是否立项:立项则按 §2 实现 `POST /tasks/{id}/release`;
不立项则 `release_blocked` 继续只随合并响应返回,发布仍无
服务端强制——该风险须用户知晓。
1. 发布端点已实现(`POST /tasks/{id}/release`,见 §2);
剩余待拍板的是锚点校验是否自动接进发布流程(见 §5.2),
而非端点本身是否立项。
2. 锚点存放:对象存储选型(厂商/桶/账号)与过渡方案是否启用。
3. 校验周期:推荐每日定时 + 每次发布前;是否接受该周期。
4. 告警渠道:校验失败通知全局管理员与当值运维的具体渠道。