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-04 21:03:32 +00:00
1 parent f16941347f
commit 27c2acd5d0
9 files changed
+1263 -2

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
"""发布证据的新鲜度与单次使用绑定(集成 v2,代码层)。
首轮缺口(全自动 9.5 扣 0.5):发布门“重算”保证的是“不用
旧结论”,不是“证据一定新鲜”——证据体里没有时间戳/nonce
绑定,调用方把合并时同一份证据体在发布时原样重交,同样
能通过重算。本模块补这一层,但诚实边界先写清:
- 本绑定防的是**原样重放**(同一 nonce 二次提交)与**陈旧
证据**(时间戳超出窗口),不防**重盖戳**:没有签名的证据
体,调用方完全可以给旧内容盖一个新时间戳/新 nonce 再交,
本模块无法识破。防篡改/防重盖戳需要签名证据或第 5 项的
只读求证(向 GitHub 实时核对),那是另一层控制,不在本
模块冒充。
- 绑定是**调用方自愿出示**的:请求体里同时带 evidence_ts
与 evidence_nonce 才进入绑定校验;两者都不带时走旧路径
(与 #259 契约兼容,响应里标注 evidence_binding=absent,
绝不谎称已验证新鲜)。只带其一是格式错误(400),不是
“半绑定通过”。把绑定改成强制,是契约决定,须协调员/
用户在合轨时定,不在本轨悄悄改 #259。
- 单次使用语义:nonce 在**首次通过校验的提交**即被消费,
无论该次发布最终是 200 还是被其它门(未 merged、推回
阻塞)挡下——被挡的调用方下次必须取新证据重盖戳。这
是故意的:每次发布尝试都要求当次新鲜证据,而不是“一份
证据可反复试到放行”。
- nonce 存储与审计链无关:evidence_nonces 表只记 nonce、
任务 id 与时间戳,不写 audit_log、不移动 #241 链头;409
不写审计的规则不受影响。表在写路径(首次消费)才建,
读路径(校验已存在)不建表——查不到表时视为无消费记
录,不是错误。
零第三方依赖,只用标准库。时间一律用 Unix 秒(time.time),
与 full_auto.audit_record 的 ts 同口径。
"""
import os
import time
import uuid
# 新鲜度窗口:证据时间戳距提交时刻不得超过 1 小时;未来
# 容忍 5 分钟时钟偏差,超出即视为异常(不是“更新鲜所以更好”)。
MAX_AGE_SECONDS = 3600
CLOCK_SKEW_SECONDS = 300
NONCE_TABLE_DDL = (
"CREATE TABLE IF NOT EXISTS evidence_nonces("
"nonce TEXT NOT NULL PRIMARY KEY, "
"task_id TEXT NOT NULL, "
"evidence_ts DOUBLE PRECISION NOT NULL, "
"consumed_at DOUBLE PRECISION NOT NULL)"
)
def bind_evidence(evidence, now=None, nonce=None):
"""给一份证据体盖戳:返回原证据的浅拷贝 + 时间戳/nonce。
调用方(协调员侧)在**取到证据的那一刻**调用本函数,
不要在合并时盖好、发布时原样重交——那样时间戳本身
就是旧的,会被窗口校验挡下(这正是本绑定的意义)。
nonce 缺省为 uuid4 hex;调用方自带 nonce 时须保证
全局唯一(重复 nonce 即重放)。
"""
if not isinstance(evidence, dict):
raise ValueError("evidence must be a dict")
stamped = dict(evidence)
stamped["evidence_ts"] = time.time() if now is None else now
stamped["evidence_nonce"] = nonce or uuid.uuid4().hex
return stamped
def validate_bound_evidence(evidence, now=None):
"""纯函数校验绑定证据的形状与新鲜度(不查 nonce 库)。
返回 (ok, reason):ok 为 False 时 reason 为
evidence_binding_missing / evidence_binding_incomplete /
evidence_ts_not_a_number / evidence_nonce_empty /
evidence_too_old / evidence_from_future。nonce 重放由
EvidenceNonceStore.consume 判定,不在本函数(本函数
无状态,同一份证据调两次结果相同,那不是重放判定)。
"""
if not isinstance(evidence, dict):
return False, "evidence_binding_missing"
has_ts = evidence.get("evidence_ts") is not None
has_nonce = evidence.get("evidence_nonce") is not None
if not has_ts and not has_nonce:
return False, "evidence_binding_missing"
if not (has_ts and has_nonce):
return False, "evidence_binding_incomplete"
ts = evidence.get("evidence_ts")
if isinstance(ts, bool) or not isinstance(ts, (int, float)):
return False, "evidence_ts_not_a_number"
nonce = evidence.get("evidence_nonce")
if not isinstance(nonce, str) or not nonce.strip():
return False, "evidence_nonce_empty"
moment = time.time() if now is None else now
if ts > moment + CLOCK_SKEW_SECONDS:
return False, "evidence_from_future"
if moment - ts > MAX_AGE_SECONDS:
return False, "evidence_too_old"
return True, None
def binding_presence(evidence):
"""绑定出示状态:bound(成对出示)/ partial(只带其一)/
absent(两者皆无)。服务端据此决定走绑定校验、报 400
还是走旧路径(并标注 absent,绝不谎称已验证)。"""
if not isinstance(evidence, dict):
return "absent"
has_ts = evidence.get("evidence_ts") is not None
has_nonce = evidence.get("evidence_nonce") is not None
if has_ts and has_nonce:
return "bound"
if has_ts or has_nonce:
return "partial"
return "absent"
class EvidenceNonceStore:
"""nonce 单次使用存储(DB 连接由调用方传入并负责提交)。
consume 成功返回 True(首次见此 nonce);nonce 已存在
返回 False(重放)。表不存在时 consume 先建表(写路径
才建);has() 在表不存在时返回 False,不建表。
"""
def __init__(self, conn):
self.conn = conn
def ensure_table(self):
self.conn.execute(NONCE_TABLE_DDL)
def has(self, nonce):
try:
row = self.conn.execute(
"SELECT nonce FROM evidence_nonces WHERE nonce = ?",
(nonce,)).fetchone()
except Exception:
return False
return row is not None
def consume(self, task_id, nonce, evidence_ts, consumed_at=None):
"""消费一个 nonce:首次 True;已存在 False(不覆盖原行)。
调用方须在 consume 后提交(本函数不 commit:服务端
在自己的连接生命周期里提交,与其余写操作保持一致)。
"""
self.ensure_table()
if self.has(nonce):
return False
self.conn.execute(
"INSERT INTO evidence_nonces(nonce, task_id, evidence_ts,"
" consumed_at) VALUES(?, ?, ?, ?)",
(nonce, task_id, evidence_ts,
time.time() if consumed_at is None else consumed_at),
)
return True