v2 evidence: bind release evidence with ts/nonce, pin actual-only quota, ledger onboarding checklist, ledger failure outbox, read-only gate verifier (no live wiring)

- cloud/evidence_binding.py: bind/validate/nonce single-use store;
  release endpoint consumes nonce on first validated submission,
  replay -> 409 evidence_replayed, stale/future -> 409
  evidence_stale, partial -> 400, absent -> old path + label.
  Binding blocks exact replay and stale evidence, NOT restamping
  (unsigned evidence cannot prove content freshness).
- cloud/quota.py docstring: actual-only is the v2 integration
  position (already converged in v1 base via fef09ae, NOT in
  main); original PR semantics (#86 all-rows vs #90 actual-only)
  still pending coordinator confirmation at merge time.
- cloud/ledger_onboarding.py: DDL checklist referencing
  PG_DDL/PG_MARKER_DDL constants, read-only verify_ledger_ready
  and ledger_setup_report; default stays OFF, PG DDL stays an
  operator action (never claimed as executed).
- cloud/ledger_outbox.py + worker hook: ledger write failures
  go to a JSONL outbox in addition to the log and can be
  replayed; task outcome still unaffected (rule 7 kept),
  audit fail-closed untouched -- asymmetry documented, not
  silently changed.
- cloud/gate_verification.py: read-only verifier over an
  injected reader (match/mismatch/skipped); live wiring in
  merge/release is NOT possible on the current contract (no
  repo/ref in body or task row, no guaranteed trusted token),
  proven in the module header; no fake verification claimed.
- cloud/tests/test_v2_evidence.py: 18 new tests, all passing.

(cherry picked from commit bd85e33cd5e846fa6ee5d7e2440182dbd9c758b0)
This commit is contained in:
host committed 2026-11-03 02:49:26 +00:00
1 parent 2dc35bd331
commit 773945d136
9 files changed
+1263 -2

No files matched your search

+77 -2
View File
@@ -79,6 +79,9 @@ from cloud.peer_messages import ensure_followups_table as _peer_ensure_followups
from cloud.peer_messages import peer_message_prefix as _peer_prefix
from cloud.peer_messages import row_get as _row_get
from cloud.marker_tools import parse_spawn_defaults as _parse_spawn_defaults
from cloud.evidence_binding import EvidenceNonceStore as _EvidenceNonceStore
from cloud.evidence_binding import binding_presence as _evidence_presence
from cloud.evidence_binding import validate_bound_evidence as _validate_evidence
from cloud.merge_gate import evaluate_merge_gate as _merge_gate_evaluate
from cloud.merge_gate import protection_from_env as _merge_gate_protection
from cloud.audit_wiring import pushback_block_for_task as _pushback_block
@@ -6431,7 +6434,14 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
reader 强制;deploy.py 是运维脚本,不在本接口门禁内。
本服务没有可信渠道直连 GitHub 核实 CI/保护:门禁强制的是
“调用方提交的证据齐全且全绿”,谎报证据由审计与 PR/Checks
只读核对覆盖,不在本接口的验证范围内。
只读核对覆盖,不在本接口的验证范围内。集成 v2 补充
(cloud/gate_verification):只读核对器本身已实现
(注入式 reader、逐项比对、mismatch 列出),但当前
合并/发布路径因请求体与任务行均无 repo/ref、且无可
信求证通道保证而接不上实时求证——见该模块头的不可
实现证明;当前真实控制仍是“证据齐全全绿 + 审计记
outcome/blocks”,审计 detail 不存证据体全文,谎报
发现靠事后比对,不是当场发现。
"""
row = self._fetch_task(task_id)
if row is None:
@@ -6992,7 +7002,16 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
把它绑到任务链上会在最需要部署修复与回滚时反锁部署。
200 时写一条显式的 release_task 审计作为发布后动作记
录(检查本身是只读的,这条是检查完成后的显式动作,
不是检查的副产品;锚点须在这条之后重取)。"""
不是检查的副产品;锚点须在这条之后重取)。
证据绑定(集成 v2,cloud/evidence_binding):请求体成
对带 evidence_ts / evidence_nonce 时,服务端校验新鲜
度窗口并单次消费 nonce,原样重交即 409
release_blocked_evidence_replayed,陈旧/未来时间戳即
409 release_blocked_evidence_stale;两者皆无时走旧路径
并在成功响应标注 evidence_binding=absent。绑定防原样
重放与陈旧证据,不防重盖戳(无签名);实时求证见
cloud/gate_verification(当前路径因无 repo/ref 且无
可信求证通道而接不上,见该模块头的不可实现证明)。"""
row = self._fetch_task(task_id)
if row is None:
self._send_json(404, {"error": "task not found"})
@@ -7014,6 +7033,58 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
if hard_err:
self._send_json(400, {"error": hard_err})
return
# 证据新鲜度与单次使用绑定(集成 v2,cloud/evidence_binding):
# 体里成对带 evidence_ts / evidence_nonce 时才校验;两者
# 皆无走旧路径(#259 契约兼容,成功响应标注 absent);
# 只带其一是格式错误 400。校验通过的 nonce 在此次提交
# 即消费(无论后续门禁放行与否),原样重交同一 nonce
# 即 409 evidence_replayed;陈旧/未来时间戳即 409
# evidence_stale。409 按本接口规则不写审计;nonce
# 消费写 evidence_nonces 表(与审计链无关,不移动链头)。
# 绑定防原样重放与陈旧证据,不防重盖戳(无签名证据
# 无法识破旧内容盖新戳——见 evidence_binding 模块头)。
binding = _evidence_presence(body)
if binding == "partial":
self._send_json(400, {"error":
"evidence_ts and evidence_nonce"
" must be supplied together"})
return
if binding == "bound":
ok, reason = _validate_evidence(body)
if not ok:
if reason in ("evidence_too_old",
"evidence_from_future"):
self._send_json(409, {
"error": "release blocked",
"release_gate": {
"allowed": False, "http_status": 409,
"code": "release_blocked_evidence_stale",
"blocked_by": "evidence_binding",
"blocked_sources": ["evidence_binding"],
},
})
else:
self._send_json(400, {"error": reason})
return
_bconn = self._db()
try:
_store = _EvidenceNonceStore(_bconn)
if not _store.consume(task_id,
body["evidence_nonce"],
body["evidence_ts"]):
self._send_json(409, {
"error": "release blocked",
"release_gate": {
"allowed": False, "http_status": 409,
"code": "release_blocked_evidence_replayed",
"blocked_by": "evidence_binding",
"blocked_sources": ["evidence_binding"],
},
})
return
_bconn.commit()
finally:
_bconn.close()
# 发布时刻的新证据:体里没给任何硬门禁字段时,hard_gate
# 为 None -> 证据缺失 409,而不是用合并时的旧结论补。
has_evidence = any(
@@ -7060,6 +7131,10 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
store.audit(user_id, task["project_id"], "release_task",
task_id)
task["release_gate"] = decision
# 绑定出示标注(绝不谎称):bound = 本次提交带时间戳
# /nonce 且通过单次校验;absent = 未出示绑定,走 #259
# 旧路径(证据仍经硬门禁重算,但新鲜度未绑定)。
task["evidence_binding"] = binding
self._send_json(200, task)
# -- 运行时审批队列 ------------------------------------------------