"""防篡改审计账本:哈希链(审计加固第 4 轨)。 方案取舍(二选一,本轨选 ①,②被否原因见下): - ① 哈希链(本模块):每条记录存 ``prev_hash``(上一条的 ``entry_hash``)与 ``entry_hash``(本条规范载荷的 SHA-256)。 改一条历史记录 → 重算哈希与存量不一致,校验失败;删一条 中间记录 → 下一条的 ``prev_hash`` 悬空,校验失败;删尾巴 → 链自洽但头哈希变了,需与此前导出的头哈希比对才能发现 (见 ``verify_chain(expected_head=...)`` / ``verify_export``)。 - ② 只追加约束 + 外部导出(被否):触发器只能挡“走正常 SQL 的误写”,有写库权限的攻击者可先删触发器再改数据;导出若 没有逐条哈希绑定,改库后重新导出看起来与原导出无异—— 检测能力依赖导出时点而非记录本身。本模块仍提供导出 (``export_chain``),但导出只是头哈希的外部锚点分发手段, 检测能力来自哈希链本身,不是“②独立落地”。 诚实边界: - 链防的是“事后悄悄改历史被当成没发生”,不是防超级用户: 能删触发器、能重建整条链并伪造头哈希的人,任何库内机制 都拦不住;可信锚点必须落在库外(导出的头哈希定期存到 库外,如运维笔记、另一系统、离线备份),校验时比对。 - 尾巴删除单靠库内校验发现不了:没有“之后还有记录”的证据 时,截断后的链自洽。这是哈希链的固有局限,不假装解决; 对策就是导出头哈希 + 定期校验(头哈希变了或期望头对不上 即告警)。 - 并发:``prev_hash`` / ``entry_hash`` 均 UNIQUE。两路并发追加 读到同一头时,一路因唯一冲突失败——调用方必须在事务边界 重试整对写入(见 ``AuthStore.audit`` 的重试循环),绝不能 靠放宽约束让链分叉。 - ``created_at`` 一律按 float 规范化后入哈希:PG 的 REAL 是 float4 会丢精度,本表在两端都用 DOUBLE PRECISION,入库前 也先转 float,避免 int/float 字面量差异导致重算失配。 历史迁移(绝不悄悄重写历史): - 老 ``audit_log`` 行没有哈希,迁移时不给它们补哈希、不改 它们、不往 ``audit_log`` 加列。``migrate_legacy_audit_log`` 只在链为空且老表有行时写一条 ``ledger_genesis`` 标记条目, 其 detail 记老表行数与 id/时间边界,声明“标记之前的一律 是 pre-chain、未上链”。链已启动后迁移函数拒绝执行。 - 链为空且老表也有 0 行时不写标记,首条真实记录直接以 ``GENESIS_HASH`` 为 prev。 第 3 轨自动推回接线口(本轨只定义接口,第 3 轨尚未落地): - ``append_pushback_round``:每轮推回在账本里留痕,字段见 函数 docstring。推时写一条(round_status="pushed"),重审 后再写一条(同 round_no,round_status="re_reviewed",带 fix_diff 与 re_review_result)——账本只追加不修改,一轮的 两条记录即完整轨迹。 - 注意:这是本轨提出的契约,第 3 轨落地时须按此字段名接线; 若第 3 轨已定其它字段名,以第 3 轨为准、本模块加兼容层, 摘要里会写清不一致处(当前:无已落地实现可比对)。 第 3 轨口径同步(失败计数 / 推倒重做 / 时间线,本轨提案): - 三种条目(action 区分,同一条链、同一时间线): ``audit_verdict``(一次审核或验收结论)、 ``audit_pushback_round``(一轮自动推回)、 ``audit_rebuild``(一次推倒重做判定 / 完成)。 - 字段表(严格类型、枚举值见各函数;第 3 轨须用这些 字段名与枚举值,否则计数与时间线分组对不上): verdict: module_id, contract_version, verdict=pass|fail, source=audit|acceptance, failures[]; pushback: round_no(推回轮次,1-3;>3 应走推倒,时间线 会标异常)、trigger_items[], pushback_package{}, fix_diff{summary,diff_sha256,files_changed}|None, re_review_result{verdict}|None, round_status=pushed| re_reviewed, module_id, contract_version, failure_source=audit|acceptance|None, failure_counts{audit_fails,acceptance_fails,total_fails}|None; rebuild: module_id, contract_version, rebuild_no>=1, old_impl_ref{commit|tag|archive_ref 至少其一}, new_impl_ref 同形或 None, root_cause=implementation| contract_design, status=decided|rebuilt, prior_failure_total|None。 - 计数规则(由账本派生,不采信调用方自报的数字): 按 (module_id, contract_version) 计;一次 pass 清零; 合同版本变了即新 key、重新计;一次已完成的重做 (status=rebuilt)同样重置计数基线(新实现从零计); total = 自基线以来的 audit fail + acceptance fail; total > 3(即第 4 次仍没通过)触发推倒,不再发第 4 轮 推回。推倒只影响该 module_id 的条目,不动其它模块。 - 根因分流:root_cause=implementation 才走代码推倒重做; root_cause=contract_design 不自动重做代码(转 G2 重新冻结 并升级人工复核或用户),账本只记录该判定,不授权重做。 - 时间线复原:``audit_timeline`` 按 id 升序返回三种条目 的解析后序列与汇总;账本只追加,失败轮次永远留在链上, 最终 pass 只是在序列末尾多一条 pass,绝不抹掉前面。 三条收紧(本轨提案,第 3 轨落地时按此接线): - 第三次失败先判根因(``audit_third_failure_assessment``): determination=early_rebuild(结构性错误,提前推倒, max_extra_rounds=0)或 continuation_round(接近完成只差 收尾,再给 1 轮 targeted 修复,max_extra_rounds=1,再 fail 即推倒);basis 必须附判定依据(建议键 audit_conclusion、 failure_items、diff_scope、passed_items、 continuation_counting——续轮计数方式),事后可抽查。 assessment 正常在 failure_total=3 时写;时间线会把 failure_total != 3 的 assessment 标为异常。 - 推倒保留资产(rebuild 条目的 retained_assets dict): old_test_set_ref(旧测试集引用)、passed_acceptance_items (已通过验收项清单)、old_new_impl_mapping(新旧实现 对应关系)、old_tests_status=pass|fail|pending(新实现 第一道门——旧测试全过情况;过不了即重做失败,调用方须 另写一条 verdict fail 计入失败计数,账本不自动补写)。 推倒只推倒实现代码:测试、已通过验收项、合同、设计、 审计结论全部保留(账本条目本身即保留证据)。 - 删改测试单独审计(``audit_test_change``):test_changes {added,modified,deleted}、auditor(经谁审计,必填)、 contract_refrozen(是否走合同重新冻结,严格 bool)、 refreeze_ref(重新冻结的合同版本或决策引用,refrozen 时必填)。硬门:modified/deleted 非空且 contract_refrozen 为 False 时拒绝写入——删改测试不走重新冻结的记录本身 就是违规,不应进账本当成合规记录(违规尝试请走 verdict fail 或违规上报另记)。 - 审计的审计(``audit_meta_review``):事后抽查发现判错时 记 original_entry_id(原结论的链 id)、original_verdict、 corrected_verdict、misjudgment_found、findings、 triggered_process(纠正后新触发的流程,如 re-review / rebuild / none)、reviewer。计数语义:时间线保留原结论 与纠正两条记录(不抹);派生计数用纠正后的有效结论 (同一原条目的纠正以后一条为准),纠正不改变链上历史。 G6 人工验收不能被绕过:meta-review 条目没有任何“代替 G6”的字段与效力,G6 仍只能用户勾。 - 验收推倒前用户通知与确认(``audit_rebuild_notice``): notification_content(通知内容,str 或 dict)、 notified_party、confirmation_status=pending|confirmed| rejected、confirmed_by(confirmed 时必填)。 硬门:trigger_source=acceptance 的 rebuild 写入时,账本 内必须已有一条同 (task_id, module_id, contract_version, rebuild_no) 的 confirmed 通知,否则拒绝写入——未确认 不得重开。audit 触发的推倒不走此门。 PG 只追加护栏:SQLite 触发器由 ``ensure_schema`` 自动建; PG 触发器有且仅有一个受控执行入口—— ``cloud/ledger_pg_guard.py`` 的 ``apply_pg_guard``(运维命令 ``python3 -m cloud.ledger_pg_guard``),它逐条执行且仅执行 本模块的 ``PG_GUARD_SQL`` 常量,不另写一份 SQL。其它任何路径 (``ensure_schema``、POSTGRES_SCHEMA 建表、迁移脚本)都不执行 PG 触发器:本机无真实 PG,触发器语法未在真实 PG 上验证, 真实 PG 上的语法与 UPDATE/DELETE 阻挡验证属于 B5 环境到齐后 另做,本项只做代码侧入口,绝不冒充“已加固”。 PG 表本身走 ``cloud/db.py`` 的 POSTGRES_SCHEMA(与老库 初始化同款全量执行,新表自动补建)。 零第三方依赖,只用标准库。 """ import hashlib import json import sqlite3 import time GENESIS_HASH = hashlib.sha256(b"audit-ledger-genesis-v1").hexdigest() LEDGER_GENESIS_ACTION = "ledger_genesis" PUSHBACK_ACTION = "audit_pushback_round" VERDICT_ACTION = "audit_verdict" REBUILD_ACTION = "audit_rebuild" ASSESSMENT_ACTION = "audit_third_failure_assessment" TEST_CHANGE_ACTION = "audit_test_change" META_REVIEW_ACTION = "audit_meta_review" REBUILD_NOTICE_ACTION = "audit_rebuild_notice" # Wiring compat action (audit-gap-1 wiring, PR #244 -> #241): track 3 # event kinds with no native track-4 action -- escalation outcomes, # blocked-quota outcomes, and audit-sample selections. These are # annotations on the timeline, not verdicts: they never feed # module_failure_counts, and only the blocking kinds move the # wiring gate (see cloud/audit_wiring.py). The action name is # intentionally generic so no second ledger vocabulary grows here: # one compat action, with the track-3 kind inside the detail. WIRING_EVENT_ACTION = "audit_wiring_event" WIRING_KINDS = ("escalate", "escalate_refreeze", "blocked_quota", "audit_sample") TIMELINE_ACTIONS = (VERDICT_ACTION, PUSHBACK_ACTION, REBUILD_ACTION, ASSESSMENT_ACTION, TEST_CHANGE_ACTION, META_REVIEW_ACTION, REBUILD_NOTICE_ACTION, WIRING_EVENT_ACTION) _VERDICT_VALUES = ("pass", "fail") _FAILURE_SOURCES = ("audit", "acceptance") _ROOT_CAUSES = ("implementation", "contract_design") _REBUILD_STATUSES = ("decided", "rebuilt") _DETERMINATIONS = ("early_rebuild", "continuation_round") _CONFIRM_STATUSES = ("pending", "confirmed", "rejected") _OLD_TESTS_STATUSES = ("pass", "fail", "pending") _IMPL_REF_KEYS = ("commit", "tag", "archive_ref") _LEDGER_TABLE_SQL = ( "CREATE TABLE IF NOT EXISTS audit_ledger(" "id INTEGER PRIMARY KEY AUTOINCREMENT, " "user_id TEXT, " "project_id TEXT, " "task_id TEXT, " "action TEXT, " "detail TEXT, " "created_at DOUBLE PRECISION, " "prev_hash TEXT NOT NULL UNIQUE, " "entry_hash TEXT NOT NULL UNIQUE)" ) # SQLite 只追加触发器(PG 不走这段,见模块 docstring)。 _SQLITE_GUARD_SQL = ( "CREATE TRIGGER IF NOT EXISTS audit_ledger_no_update " "BEFORE UPDATE ON audit_ledger BEGIN " "SELECT RAISE(ABORT, 'audit_ledger is append-only'); END", "CREATE TRIGGER IF NOT EXISTS audit_ledger_no_delete " "BEFORE DELETE ON audit_ledger BEGIN " "SELECT RAISE(ABORT, 'audit_ledger is append-only'); END", ) # PG 只追加护栏:手动执行用,不自动执行(未在真实 PG 验证)。 PG_GUARD_SQL = ( "CREATE OR REPLACE FUNCTION audit_ledger_no_mutation() " "RETURNS trigger AS $$ BEGIN " "RAISE EXCEPTION 'audit_ledger is append-only'; END; " "$$ LANGUAGE plpgsql", "DROP TRIGGER IF EXISTS audit_ledger_no_update ON audit_ledger", "CREATE TRIGGER audit_ledger_no_update BEFORE UPDATE ON audit_ledger " "FOR EACH ROW EXECUTE FUNCTION audit_ledger_no_mutation()", "DROP TRIGGER IF EXISTS audit_ledger_no_delete ON audit_ledger", "CREATE TRIGGER audit_ledger_no_delete BEFORE DELETE ON audit_ledger " "FOR EACH ROW EXECUTE FUNCTION audit_ledger_no_mutation()", ) def _is_pg_conn(conn): """PgConnection 适配连接(触发器方言不同,须跳过 SQLite 护栏)。""" return type(conn).__name__ == "PgConnection" def _is_integrity_error(exc): if isinstance(exc, sqlite3.IntegrityError): return True return type(exc).__name__ in ("PostgresIntegrityError",) def ensure_schema(conn): """建 audit_ledger 表(幂等)+ SQLite 只追加触发器。 不 commit:调用方在自己的事务边界提交(AuthStore.__init__ 随后即 commit;独立调用方须自己 commit)。 PG 适配连接跳过触发器建(方言不同):PG 触发器只能经唯一 受控入口 cloud/ledger_pg_guard.apply_pg_guard 执行,绝不在 这里自动建(见模块头 PG 段与 PG_GUARD_SQL)。 """ conn.execute(_LEDGER_TABLE_SQL) if not _is_pg_conn(conn): for stmt in _SQLITE_GUARD_SQL: conn.execute(stmt) def _canonical(prev_hash, user_id, project_id, task_id, action, detail, created_at): payload = { "prev_hash": prev_hash, "user_id": user_id or "", "project_id": project_id or "", "task_id": task_id or "", "action": action or "", "detail": detail or "", "created_at": float(created_at), } return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) def compute_entry_hash(prev_hash, user_id, project_id, task_id, action, detail, created_at): """本条 entry_hash:规范载荷的 SHA-256 hex。""" return hashlib.sha256( _canonical(prev_hash, user_id, project_id, task_id, action, detail, created_at).encode("utf-8")).hexdigest() def _head_hash(conn): row = conn.execute( "SELECT entry_hash FROM audit_ledger ORDER BY id DESC LIMIT 1" ).fetchone() return row[0] if row is not None else GENESIS_HASH def _table_exists(conn, table): try: row = conn.execute( "SELECT COUNT(*) FROM %s" % table).fetchone() except Exception: return False return row is not None def append_entry(conn, user_id, project_id, action, detail="", task_id=None, created_at=None): """在给定连接上追加一条链记录(单次尝试,不 commit、不重试)。 调用方拥有事务:成功后由调用方 commit;唯一冲突(并发抢头) 时调用方须 rollback 后重试整对写入(audit_log 行 + 本条), 绝不能只重试本条——回滚会连同调用方的其它写入一起丢掉, 只重试本条会造成 audit_log 缺行、链多行。 返回新条目 dict(含 id/prev_hash/entry_hash)。 """ ensure_schema(conn) if created_at is None: created_at = time.time() created_at = float(created_at) prev_hash = _head_hash(conn) entry_hash = compute_entry_hash( prev_hash, user_id, project_id, task_id, action, detail, created_at) cur = conn.execute( "INSERT INTO audit_ledger(user_id, project_id, task_id, action," " detail, created_at, prev_hash, entry_hash)" " VALUES(?, ?, ?, ?, ?, ?, ?, ?)", (user_id, project_id, task_id, action, detail, created_at, prev_hash, entry_hash), ) entry_id = cur.lastrowid if entry_id is None: # PG 适配在 _IDENTITY_TABLES 外的表上 lastrowid 为空时 # 的兜底:entry_hash 唯一,可据此找回刚写的一行。 row = conn.execute( "SELECT id FROM audit_ledger WHERE entry_hash = ?", (entry_hash,)).fetchone() entry_id = row[0] if row is not None else None return { "id": entry_id, "user_id": user_id, "project_id": project_id, "task_id": task_id, "action": action, "detail": detail, "created_at": created_at, "prev_hash": prev_hash, "entry_hash": entry_hash, } def _rows_as_entries(rows): entries = [] for row in rows: entries.append({ "id": row[0], "user_id": row[1], "project_id": row[2], "task_id": row[3], "action": row[4], "detail": row[5], "created_at": row[6], "prev_hash": row[7], "entry_hash": row[8], }) return entries def _check_entries(entries, expected_head=None): """纯函数校验: linkage + 重算哈希 + 头比对。返回 failures。""" failures = [] prev = GENESIS_HASH last_id = None for entry in entries: entry_id = entry["id"] if last_id is not None and entry_id <= last_id: failures.append("entries out of order at id %r" % (entry_id,)) last_id = entry_id if entry["prev_hash"] != prev: failures.append( "chain broken before id %r: prev_hash does not match" " previous entry_hash (record deleted or reordered)" % (entry_id,)) # 断链后仍继续重算每条自身哈希,尽量多报问题。 recomputed = compute_entry_hash( entry["prev_hash"], entry["user_id"], entry["project_id"], entry["task_id"], entry["action"], entry["detail"], entry["created_at"]) if recomputed != entry["entry_hash"]: failures.append( "entry_hash mismatch at id %r (record tampered)" % (entry_id,)) prev = entry["entry_hash"] head = prev if entries else GENESIS_HASH if expected_head is not None and head != expected_head: failures.append( "head mismatch: chain head %s != expected %s" " (tail truncated or chain replaced)" % (head, expected_head)) return failures, head def _legacy_summary(conn): """老 audit_log 概况:行数(表不存在时为 None)。""" if not _table_exists(conn, "audit_log"): return None row = conn.execute("SELECT COUNT(*) FROM audit_log").fetchone() return int(row[0]) if row is not None else 0 def verify_chain(conn, expected_head=None): """校验库内整条链。返回 dict(ok 为 False 时 failures 逐条说明)。 链为空且老 audit_log 有行 → ok=False(legacy_not_migrated): 必须先跑 migrate_legacy_audit_log 再认账本完整,否则老历史 会被误读成“链上无记录即无事发生”。 """ if not _table_exists(conn, "audit_ledger"): return { "ok": False, "failures": ["audit_ledger table missing: ledger not" " initialized"], "count": 0, "head_hash": GENESIS_HASH, "genesis_marker": None, "legacy_audit_rows": _legacy_summary(conn), } rows = conn.execute( "SELECT id, user_id, project_id, task_id, action, detail," " created_at, prev_hash, entry_hash FROM audit_ledger" " ORDER BY id ASC").fetchall() entries = _rows_as_entries(rows) failures, head = _check_entries(entries, expected_head=expected_head) genesis_marker = None if entries and entries[0]["action"] == LEDGER_GENESIS_ACTION: try: parsed = json.loads(entries[0]["detail"] or "") except ValueError: parsed = None if isinstance(parsed, dict) and parsed.get("pre_chain"): genesis_marker = parsed legacy = _legacy_summary(conn) if not entries and legacy: failures.append( "legacy_not_migrated: %d audit_log rows predate the chain;" " run migrate_legacy_audit_log before trusting this ledger" % legacy) return { "ok": not failures, "failures": failures, "count": len(entries), "head_hash": head, "genesis_marker": genesis_marker, "legacy_audit_rows": legacy, } def export_chain(conn, limit=None): """导出整条链(头哈希即外部锚点)。limit 为 None 时全量。""" if not _table_exists(conn, "audit_ledger"): return {"version": 1, "entries": [], "count": 0, "head_hash": GENESIS_HASH} sql = ("SELECT id, user_id, project_id, task_id, action, detail," " created_at, prev_hash, entry_hash FROM audit_ledger" " ORDER BY id ASC") params = () if limit is not None: sql += " LIMIT ?" params = (int(limit),) rows = conn.execute(sql, params).fetchall() entries = _rows_as_entries(rows) _failures, head = _check_entries(entries) return {"version": 1, "entries": entries, "count": len(entries), "head_hash": head} def verify_export(data, expected_head=None): """校验一次导出(不碰数据库)。entries 须含全部校验字段。""" if not isinstance(data, dict) or not isinstance( data.get("entries"), list): return {"ok": False, "failures": ["export shape invalid: entries missing"], "count": 0, "head_hash": GENESIS_HASH} entries = [] failures = [] for raw in data["entries"]: try: entries.append({ "id": raw["id"], "user_id": raw["user_id"], "project_id": raw["project_id"], "task_id": raw["task_id"], "action": raw["action"], "detail": raw["detail"], "created_at": float(raw["created_at"]), "prev_hash": raw["prev_hash"], "entry_hash": raw["entry_hash"], }) except (KeyError, TypeError, ValueError): failures.append("export entry shape invalid: %r" % (raw,)) chain_failures, head = _check_entries(entries, expected_head=expected_head) failures.extend(chain_failures) if data.get("head_hash") is not None \ and data["head_hash"] != head: failures.append( "export head_hash %s != recomputed head %s" % (data["head_hash"], head)) return {"ok": not failures, "failures": failures, "count": len(entries), "head_hash": head} def migrate_legacy_audit_log(conn): """老账本迁移(幂等、只执行一次、绝不改写历史)。 链已有行 → 拒绝(reason=chain already started),不补、不插、 不改。链空且老表 0 行 → 无事可做,不写标记。链空且老表有行 → 写一条 ledger_genesis 标记,detail 记老表边界;调用方 commit 后,verify_chain 的 legacy_not_migrated 即消失。 本函数不 commit,调用方提交。 """ ensure_schema(conn) existing = conn.execute( "SELECT COUNT(*) FROM audit_ledger").fetchone() if existing is not None and int(existing[0]) > 0: return {"migrated": False, "reason": "chain already started", "marker": None} if not _table_exists(conn, "audit_log"): return {"migrated": False, "reason": "no audit_log table", "marker": None} row = conn.execute( "SELECT COUNT(*), MIN(id), MAX(id), MAX(created_at)" " FROM audit_log").fetchone() count = int(row[0]) if row is not None else 0 if count == 0: return {"migrated": False, "reason": "no legacy rows", "marker": None} detail = json.dumps({ "pre_chain": True, "legacy_audit_rows": count, "legacy_min_id": row[1], "legacy_max_id": row[2], "legacy_max_created_at": row[3], "note": "Rows before this marker live only in audit_log," " unhashed and never rewritten; they are NOT covered" " by the hash chain.", }, sort_keys=True, ensure_ascii=False) marker = append_entry( conn, "system", "", LEDGER_GENESIS_ACTION, detail=detail, created_at=(float(row[3]) if row[3] is not None else None)) return {"migrated": True, "reason": None, "marker": marker} def _require_non_empty_str(value, name): if not isinstance(value, str) or not value: raise ValueError("%s must be a non-empty string" % name) return value def _require_int_ge(value, name, minimum): if isinstance(value, bool) or not isinstance(value, int) \ or value < minimum: raise ValueError("%s must be an int >= %d" % (name, minimum)) return value def _require_impl_ref(ref, name): if not isinstance(ref, dict): raise ValueError("%s must be a dict" % name) if not any(isinstance(ref.get(k), str) and ref[k] for k in _IMPL_REF_KEYS): raise ValueError( "%s must carry at least one of commit/tag/archive_ref" % name) return ref def append_pushback_round(conn, task_id, project_id, round_no, trigger_items, pushback_package, fix_diff=None, re_review_result=None, user_id="system", created_at=None, module_id=None, contract_version=None, failure_source=None, failure_counts=None): """第 3 轨自动推回接线口:一轮推回写一条链记录。 字段契约(本轨提案,第 3 轨落地时按此接线): - task_id: str(必填,轮次归属的任务); - round_no: int >= 1(第几轮推回;按规则推回只到 3 轮, round_no > 3 的推回条目会被时间线标为异常——第 4 次 失败应走推倒,不应再推回;这里不硬拒是怕调用方按 任务全局编号,硬拒会把编号口径分歧变成写入失败); - trigger_items: 非空 list(触发推回的失败项,str 或 dict); - pushback_package: dict(推回包:逐条失败项、证据、复现、 修复验收标准、上一轮产物哈希;多余键原样保存并入哈希); - fix_diff: dict 或 None(修复摘要:建议键 summary、 diff_sha256、files_changed;只存摘要与哈希,不存全文, 全文在任务产物里、哈希绑定); - re_review_result: dict 或 None(重审结果:建议键 verdict 取 pass/fail,CONDITIONAL 不算通过是第 3 轨的判定语义, 账本只原样记录); - round_status: 推时 "pushed",重审后 "re_reviewed",由 re_review_result 是否为空推导;一轮留两条记录即完整轨迹。 - module_id / contract_version: str 或 None(推回归属的 模块与合同版本;计数口径要求这两项,缺了时间线仍能 列出条目,但 module_failure_counts 无法把它归入计数); - failure_source: audit|acceptance 或 None(触发本轮的 那次失败的来源); - failure_counts: dict 或 None(推回时点的计数快照,建议 键 audit_fails/acceptance_fails/total_fails;快照只供 交叉核对,计数以 module_failure_counts 的派生为准)。 校验失败抛 ValueError,绝不写半条记录。 """ _require_non_empty_str(task_id, "task_id") _require_int_ge(round_no, "round_no", 1) if not isinstance(trigger_items, list) or not trigger_items: raise ValueError("trigger_items must be a non-empty list") if not isinstance(pushback_package, dict): raise ValueError("pushback_package must be a dict") if fix_diff is not None and not isinstance(fix_diff, dict): raise ValueError("fix_diff must be a dict or None") if re_review_result is not None \ and not isinstance(re_review_result, dict): raise ValueError("re_review_result must be a dict or None") if module_id is not None: _require_non_empty_str(module_id, "module_id") if contract_version is not None: _require_non_empty_str(contract_version, "contract_version") if failure_source is not None \ and failure_source not in _FAILURE_SOURCES: raise ValueError( "failure_source must be audit|acceptance or None") if failure_counts is not None \ and not isinstance(failure_counts, dict): raise ValueError("failure_counts must be a dict or None") detail = json.dumps({ "round_no": round_no, "trigger_items": trigger_items, "pushback_package": pushback_package, "fix_diff": fix_diff, "re_review_result": re_review_result, "round_status": ("re_reviewed" if re_review_result is not None else "pushed"), "module_id": module_id, "contract_version": contract_version, "failure_source": failure_source, "failure_counts": failure_counts, }, sort_keys=True, ensure_ascii=False) return append_entry(conn, user_id, project_id, PUSHBACK_ACTION, detail=detail, task_id=task_id, created_at=created_at) def list_pushback_rounds(conn, task_id=None, project_id=None, limit=100): """按 id 升序返回推回轮次记录(detail 已解析,解析失败不炸)。""" sql = ("SELECT id, user_id, project_id, task_id, action, detail," " created_at, prev_hash, entry_hash FROM audit_ledger" " WHERE action = ?") params = [PUSHBACK_ACTION] if task_id is not None: sql += " AND task_id = ?" params.append(task_id) if project_id is not None: sql += " AND project_id = ?" params.append(project_id) sql += " ORDER BY id ASC LIMIT ?" params.append(int(limit)) rows = conn.execute(sql, tuple(params)).fetchall() out = [] for entry in _rows_as_entries(rows): parsed = None parse_error = None try: parsed = json.loads(entry["detail"] or "") except ValueError as exc: parse_error = str(exc) entry["pushback"] = parsed entry["pushback_parse_error"] = parse_error out.append(entry) return out def append_audit_verdict(conn, task_id, project_id, module_id, contract_version, verdict, source, failures=None, user_id="system", created_at=None, extra=None): """记一次审核 / 验收结论(失败计数的唯一事实源)。 verdict 取 pass|fail(CONDITIONAL 不算通过:调用方须把 CONDITIONAL 映射成 fail 再记,账本不替调用方解释 verdict 语义);source 取 audit|acceptance(分来源计数用)。 failures 为失败项 list(可空);extra 为调用方附加 dict。 校验失败抛 ValueError,绝不写半条记录。 """ _require_non_empty_str(task_id, "task_id") _require_non_empty_str(module_id, "module_id") _require_non_empty_str(contract_version, "contract_version") if verdict not in _VERDICT_VALUES: raise ValueError("verdict must be pass|fail") if source not in _FAILURE_SOURCES: raise ValueError("source must be audit|acceptance") if failures is not None and not isinstance(failures, list): raise ValueError("failures must be a list or None") if extra is not None and not isinstance(extra, dict): raise ValueError("extra must be a dict or None") detail = json.dumps({ "module_id": module_id, "contract_version": contract_version, "verdict": verdict, "source": source, "failures": failures or [], "extra": extra or {}, }, sort_keys=True, ensure_ascii=False) return append_entry(conn, user_id, project_id, VERDICT_ACTION, detail=detail, task_id=task_id, created_at=created_at) def _has_confirmed_rebuild_notice(conn, task_id, module_id, contract_version, rebuild_no): """库内是否已有同任务/模块/合同版本/推倒号的 confirmed 通知。""" rows = conn.execute( "SELECT detail FROM audit_ledger WHERE action = ?" " AND task_id = ? ORDER BY id ASC", (REBUILD_NOTICE_ACTION, task_id)).fetchall() for row in rows: try: parsed = json.loads(row[0] or "") except ValueError: continue if not isinstance(parsed, dict): continue if parsed.get("module_id") == module_id \ and parsed.get("contract_version") == contract_version \ and parsed.get("rebuild_no") == rebuild_no \ and parsed.get("confirmation_status") == "confirmed": return True return False def append_rebuild_decision(conn, task_id, project_id, module_id, contract_version, rebuild_no, old_impl_ref, new_impl_ref=None, root_cause="implementation", status="decided", prior_failure_total=None, user_id="system", created_at=None, retained_assets=None, trigger_source=None, assessment_entry_id=None): """记一次推倒重做判定 / 完成(只影响该 module_id)。 - rebuild_no: int >= 1,按 (module_id, contract_version) 计(合同版本变了重新计;跨版本总数由时间线汇总另计)。 - old_impl_ref: dict,commit/tag/archive_ref 至少其一—— 旧实现以提交/tag/归档保留在 git 历史与审计记录里, 本条目只记引用,不删旧实现。 - new_impl_ref: dict 或 None;status=rebuilt 时必填(同形 引用),status=decided 时可空(新实现尚未产生)。 - root_cause: implementation(代码实现问题,走代码推倒) 或 contract_design(合同/设计问题,不自动重做代码, 转 G2 重新冻结并升级人工复核或用户;本条目只记录 该判定,不授权任何重做)。 - prior_failure_total: 触发本次推倒时的累计失败数(快照, 供交叉核对;触发判定以 module_failure_counts 为准)。 - retained_assets: dict 或 None(推倒保留资产:建议键 old_test_set_ref、passed_acceptance_items、 old_new_impl_mapping、old_tests_status=pass|fail| pending;新实现第一道门是旧测试全过,过不了调用方 须另写 verdict fail 计入计数,账本不自动补写)。 - trigger_source: audit|acceptance 或 None(触发推倒的 失败来源)。acceptance 时硬门:库内必须已有同 (task_id, module_id, contract_version, rebuild_no) 的 confirmed 用户通知,否则拒绝写入(未确认不得重开); audit 触发不走此门。 - assessment_entry_id: 第三次失败判定条目的链 id 或 None。 校验失败抛 ValueError,绝不写半条记录。 """ _require_non_empty_str(task_id, "task_id") _require_non_empty_str(module_id, "module_id") _require_non_empty_str(contract_version, "contract_version") _require_int_ge(rebuild_no, "rebuild_no", 1) _require_impl_ref(old_impl_ref, "old_impl_ref") if root_cause not in _ROOT_CAUSES: raise ValueError( "root_cause must be implementation|contract_design") if status not in _REBUILD_STATUSES: raise ValueError("status must be decided|rebuilt") if new_impl_ref is not None: _require_impl_ref(new_impl_ref, "new_impl_ref") if status == "rebuilt" and new_impl_ref is None: raise ValueError( "new_impl_ref is required when status is rebuilt") if prior_failure_total is not None: _require_int_ge(prior_failure_total, "prior_failure_total", 0) if retained_assets is not None: if not isinstance(retained_assets, dict): raise ValueError("retained_assets must be a dict or None") if "old_tests_status" in retained_assets \ and retained_assets["old_tests_status"] \ not in _OLD_TESTS_STATUSES: raise ValueError( "retained_assets.old_tests_status must be" " pass|fail|pending") if trigger_source is not None \ and trigger_source not in _FAILURE_SOURCES: raise ValueError( "trigger_source must be audit|acceptance or None") if assessment_entry_id is not None: _require_int_ge(assessment_entry_id, "assessment_entry_id", 1) if trigger_source == "acceptance" and not _has_confirmed_rebuild_notice( conn, task_id, module_id, contract_version, rebuild_no): raise ValueError( "acceptance-triggered rebuild requires a confirmed user" " notice (audit_rebuild_notice, confirmation_status=" "confirmed) for the same task/module/contract/rebuild_no") detail = json.dumps({ "module_id": module_id, "contract_version": contract_version, "rebuild_no": rebuild_no, "old_impl_ref": old_impl_ref, "new_impl_ref": new_impl_ref, "root_cause": root_cause, "status": status, "prior_failure_total": prior_failure_total, "retained_assets": retained_assets, "trigger_source": trigger_source, "assessment_entry_id": assessment_entry_id, }, sort_keys=True, ensure_ascii=False) return append_entry(conn, user_id, project_id, REBUILD_ACTION, detail=detail, task_id=task_id, created_at=created_at) def list_rebuild_decisions(conn, task_id=None, module_id=None, limit=100): """按 id 升序返回推倒重做记录(detail 已解析)。""" sql = ("SELECT id, user_id, project_id, task_id, action, detail," " created_at, prev_hash, entry_hash FROM audit_ledger" " WHERE action = ?") params = [REBUILD_ACTION] if task_id is not None: sql += " AND task_id = ?" params.append(task_id) # module 无独立列、在 detail 里,解析后过滤(见下)。 sql += " ORDER BY id ASC LIMIT ?" params.append(int(limit)) rows = conn.execute(sql, tuple(params)).fetchall() out = [] for entry in _rows_as_entries(rows): try: parsed = json.loads(entry["detail"] or "") except ValueError as exc: parsed = None entry["rebuild_parse_error"] = str(exc) entry["rebuild"] = parsed if module_id is not None: if not isinstance(parsed, dict) \ or parsed.get("module_id") != module_id: continue out.append(entry) return out def _parse_timeline_detail(entry): try: return json.loads(entry["detail"] or ""), None except ValueError as exc: return None, str(exc) def _count_walk(parsed_entries, corrections=None): """对已解析的 verdict/rebuild 序列派生计数。 基线移动:pass 或已完成的重做(status=rebuilt)都把 计数清零;decided(未完成)不移动基线。返回计数 dict。 corrections: {原 verdict 条目 id: 纠正后 verdict}(审计的 审计产生;同一原条目以后一条纠正为准)。计数用有效结论, 但链上原结论与纠正记录都保留,不抹历史。 """ corrections = corrections or {} counts = {"audit_fails": 0, "acceptance_fails": 0, "total_fails": 0, "last_pass_entry_id": None, "last_rebuild_entry_id": None, "last_fail_entry_id": None, "entries_considered": 0, "parse_errors": 0} for entry, parsed in parsed_entries: if entry["action"] == REBUILD_ACTION: if isinstance(parsed, dict) \ and parsed.get("status") == "rebuilt": counts["audit_fails"] = 0 counts["acceptance_fails"] = 0 counts["total_fails"] = 0 counts["last_rebuild_entry_id"] = entry["id"] counts["entries_considered"] += 1 continue if entry["action"] != VERDICT_ACTION: continue counts["entries_considered"] += 1 if not isinstance(parsed, dict): counts["parse_errors"] += 1 continue verdict = corrections.get(entry["id"], parsed.get("verdict")) if verdict == "pass": counts["audit_fails"] = 0 counts["acceptance_fails"] = 0 counts["total_fails"] = 0 counts["last_pass_entry_id"] = entry["id"] elif verdict == "fail": if parsed.get("source") == "audit": counts["audit_fails"] += 1 elif parsed.get("source") == "acceptance": counts["acceptance_fails"] += 1 counts["total_fails"] += 1 counts["last_fail_entry_id"] = entry["id"] counts["rebuild_triggered"] = counts["total_fails"] > 3 return counts def _corrections_from(parsed_meta_entries): """从审计的审计条目提有效结论映射:{原 verdict id: 纠正结论}。 同一原条目以后一条纠正为准;misjudgment_found 为 False 或无 corrected_verdict 的条目不产生纠正。 """ corrections = {} for entry, parsed in parsed_meta_entries: if entry["action"] != META_REVIEW_ACTION: continue if not isinstance(parsed, dict): continue if not parsed.get("misjudgment_found"): continue original_id = parsed.get("original_entry_id") corrected = parsed.get("corrected_verdict") if isinstance(original_id, int) and corrected in _VERDICT_VALUES: corrections[original_id] = corrected return corrections def module_failure_counts(conn, module_id, contract_version, task_id=None): """派生失败计数:按 (module_id, contract_version) 计。 只读 verdict + rebuild + meta-review 条目(全局,不截断); 调用方自报的 failure_counts 快照不参与计算。计数用有效 结论(审计的审计纠正后为准),链上原结论仍保留。 total > 3 即推倒触发。 """ _require_non_empty_str(module_id, "module_id") _require_non_empty_str(contract_version, "contract_version") sql = ("SELECT id, user_id, project_id, task_id, action, detail," " created_at, prev_hash, entry_hash FROM audit_ledger" " WHERE action IN (?, ?, ?) ORDER BY id ASC") params = [VERDICT_ACTION, REBUILD_ACTION, META_REVIEW_ACTION] if task_id is not None: sql = ("SELECT id, user_id, project_id, task_id, action," " detail, created_at, prev_hash, entry_hash" " FROM audit_ledger WHERE action IN (?, ?, ?)" " AND task_id = ? ORDER BY id ASC") params = [VERDICT_ACTION, REBUILD_ACTION, META_REVIEW_ACTION, task_id] rows = conn.execute(sql, tuple(params)).fetchall() parsed_entries = [] meta_entries = [] for entry in _rows_as_entries(rows): parsed, _err = _parse_timeline_detail(entry) if not isinstance(parsed, dict): continue if parsed.get("module_id") != module_id: continue if parsed.get("contract_version") != contract_version: continue if entry["action"] == META_REVIEW_ACTION: meta_entries.append((entry, parsed)) else: parsed_entries.append((entry, parsed)) counts = _count_walk(parsed_entries, corrections=_corrections_from(meta_entries)) counts["module_id"] = module_id counts["contract_version"] = contract_version return counts def audit_timeline(conn, task_id=None, module_id=None, project_id=None, limit=200): """复原时间线:全部审计类条目按 id 升序。 返回 {entries, summary}:entries 为解析后条目(解析失败 的条目保留 raw 并标 parse_error,绝不丢);summary 含 窗口内(limit 截断内)的 verdict 统计、推回轮次、推倒数、 第三次失败判定、测试变更单审、审计的审计、重开通知数、 按合同版本的失败计数(用有效结论,含窗口内纠正)与 异常(如推回 round_no > 3、assessment 不在第 3 次)。 窗口计数只反映窗口内条目,全局精确计数须用 module_failure_counts——summary 置 window_only=True 明示。 最终 pass 只是序列末尾的一条,不抹掉前面的失败轮次。 """ placeholders = ", ".join("?" for _ in TIMELINE_ACTIONS) sql = ("SELECT id, user_id, project_id, task_id, action, detail," " created_at, prev_hash, entry_hash FROM audit_ledger" " WHERE action IN (%s)" % placeholders) params = list(TIMELINE_ACTIONS) if task_id is not None: sql += " AND task_id = ?" params.append(task_id) if project_id is not None: sql += " AND project_id = ?" params.append(project_id) sql += " ORDER BY id ASC LIMIT ?" params.append(int(limit)) rows = conn.execute(sql, tuple(params)).fetchall() entries = [] for entry in _rows_as_entries(rows): parsed, err = _parse_timeline_detail(entry) entry["parsed"] = parsed entry["parse_error"] = err entries.append(entry) if module_id is not None: entries = [e for e in entries if isinstance(e["parsed"], dict) and e["parsed"].get("module_id") == module_id] verdict_pass = sum( 1 for e in entries if e["action"] == VERDICT_ACTION and isinstance(e["parsed"], dict) and e["parsed"].get("verdict") == "pass") verdict_fail = sum( 1 for e in entries if e["action"] == VERDICT_ACTION and isinstance(e["parsed"], dict) and e["parsed"].get("verdict") == "fail") pushback_rounds = [e for e in entries if e["action"] == PUSHBACK_ACTION] rebuilds = [e for e in entries if e["action"] == REBUILD_ACTION] anomalies = [] for e in pushback_rounds: parsed = e["parsed"] if isinstance(parsed, dict) \ and isinstance(parsed.get("round_no"), int) \ and parsed["round_no"] > 3: anomalies.append( "pushback round_no %r at id %r exceeds 3: the 4th" " failure should have been a rebuild, not a pushback" % (parsed["round_no"], e["id"])) for e in entries: parsed = e["parsed"] if e["action"] == ASSESSMENT_ACTION \ and isinstance(parsed, dict) \ and parsed.get("failure_total") != 3: anomalies.append( "third-failure assessment at id %r has failure_total" " %r, expected 3" % (e["id"], parsed.get( "failure_total"))) counts_by_contract = {} versions = sorted({ e["parsed"]["contract_version"] for e in entries if isinstance(e["parsed"], dict) and e["parsed"].get("contract_version")}) for version in versions: window = [(e, e["parsed"]) for e in entries if e["action"] in (VERDICT_ACTION, REBUILD_ACTION) and isinstance(e["parsed"], dict) and e["parsed"].get("contract_version") == version] meta = [(e, e["parsed"]) for e in entries if e["action"] == META_REVIEW_ACTION and isinstance(e["parsed"], dict) and e["parsed"].get("contract_version") == version] counts_by_contract[version] = _count_walk( window, corrections=_corrections_from(meta)) summary = { "window_only": True, "truncated": len(entries) >= int(limit), "verdict_pass": verdict_pass, "verdict_fail": verdict_fail, "pushback_rounds": len(pushback_rounds), "rebuild_decisions": len(rebuilds), "rebuilds_completed": sum( 1 for e in rebuilds if isinstance(e["parsed"], dict) and e["parsed"].get("status") == "rebuilt"), "third_failure_assessments": sum( 1 for e in entries if e["action"] == ASSESSMENT_ACTION), "test_change_audits": sum( 1 for e in entries if e["action"] == TEST_CHANGE_ACTION), "meta_reviews": sum( 1 for e in entries if e["action"] == META_REVIEW_ACTION), "rebuild_notices": sum( 1 for e in entries if e["action"] == REBUILD_NOTICE_ACTION), "wiring_events": sum( 1 for e in entries if e["action"] == WIRING_EVENT_ACTION), "counts_by_contract": counts_by_contract, "anomalies": anomalies, } return {"entries": entries, "summary": summary} def append_third_failure_assessment(conn, task_id, project_id, module_id, contract_version, determination, basis, failure_total=3, max_extra_rounds=None, user_id="system", created_at=None): """记第三次失败的根因判定(阈值不死板的一环)。 - determination: early_rebuild(结构性错误,提前推倒) 或 continuation_round(接近完成只差收尾,再给 1 轮 targeted 修复,再 fail 即推倒)。 - basis: 非空 dict,判定依据(建议键 audit_conclusion、 failure_items、diff_scope、passed_items、 continuation_counting;failure_items/passed_items 若 出现必须为 list)。依据缺失的判定不进账本。 - failure_total: 判定时的累计失败数,正常为 3;非 3 时 仍可写,但时间线标异常(计数口径分歧须事后核查)。 - max_extra_rounds: early_rebuild 时必须为 0、 continuation_round 时必须为 1;None 时按判定取值。 校验失败抛 ValueError,绝不写半条记录。 """ _require_non_empty_str(task_id, "task_id") _require_non_empty_str(module_id, "module_id") _require_non_empty_str(contract_version, "contract_version") if determination not in _DETERMINATIONS: raise ValueError( "determination must be early_rebuild|continuation_round") if not isinstance(basis, dict) or not basis: raise ValueError("basis must be a non-empty dict") for key in ("failure_items", "passed_items"): if key in basis and not isinstance(basis[key], list): raise ValueError("basis.%s must be a list" % key) _require_int_ge(failure_total, "failure_total", 1) if max_extra_rounds is None: max_extra_rounds = 0 if determination == "early_rebuild" else 1 if determination == "early_rebuild" and max_extra_rounds != 0: raise ValueError( "early_rebuild requires max_extra_rounds == 0") if determination == "continuation_round" \ and max_extra_rounds != 1: raise ValueError( "continuation_round requires max_extra_rounds == 1") detail = json.dumps({ "module_id": module_id, "contract_version": contract_version, "determination": determination, "basis": basis, "failure_total": failure_total, "max_extra_rounds": max_extra_rounds, }, sort_keys=True, ensure_ascii=False) return append_entry(conn, user_id, project_id, ASSESSMENT_ACTION, detail=detail, task_id=task_id, created_at=created_at) def append_test_change_audit(conn, task_id, project_id, module_id, contract_version, test_changes, auditor, contract_refrozen, refreeze_ref=None, reason="", user_id="system", created_at=None): """记一次删改测试的单独审计(推倒保留资产的一环)。 - test_changes: dict,键 added/modified/deleted,各为 测试标识 str 的 list;三者至少其一非空。 - auditor: 经谁审计(必填,非空 str,如审计员用户 id)。 - contract_refrozen: 严格 bool。modified/deleted 非空 且 contract_refrozen 为 False 时拒绝写入(删改测试 必须走合同重新冻结;纯新增不走此门)。 - refreeze_ref: contract_refrozen 为 True 时必填(新冻结 的合同版本或决策引用);为 False 时须为空。 - reason: 改动原因说明(可空 str)。 校验失败抛 ValueError,绝不写半条记录。 """ _require_non_empty_str(task_id, "task_id") _require_non_empty_str(module_id, "module_id") _require_non_empty_str(contract_version, "contract_version") _require_non_empty_str(auditor, "auditor") if not isinstance(test_changes, dict): raise ValueError("test_changes must be a dict") for key in ("added", "modified", "deleted"): value = test_changes.get(key, []) if not isinstance(value, list) or not all( isinstance(item, str) for item in value): raise ValueError( "test_changes.%s must be a list of strings" % key) if not any(test_changes.get(key) for key in ("added", "modified", "deleted")): raise ValueError( "test_changes must change at least one test") if not isinstance(contract_refrozen, bool): raise ValueError("contract_refrozen must be a bool") needs_refreeze = bool(test_changes.get("modified") or test_changes.get("deleted")) if needs_refreeze and not contract_refrozen: raise ValueError( "modified/deleted tests require contract_refrozen=True") if contract_refrozen: _require_non_empty_str(refreeze_ref, "refreeze_ref") elif refreeze_ref is not None: raise ValueError( "refreeze_ref must be None when contract_refrozen is False") if not isinstance(reason, str): raise ValueError("reason must be a string") detail = json.dumps({ "module_id": module_id, "contract_version": contract_version, "test_changes": { "added": test_changes.get("added", []), "modified": test_changes.get("modified", []), "deleted": test_changes.get("deleted", []), }, "auditor": auditor, "contract_refrozen": contract_refrozen, "refreeze_ref": refreeze_ref, "reason": reason, }, sort_keys=True, ensure_ascii=False) return append_entry(conn, user_id, project_id, TEST_CHANGE_ACTION, detail=detail, task_id=task_id, created_at=created_at) def append_audit_of_audit(conn, task_id, project_id, module_id, contract_version, original_entry_id, original_verdict, corrected_verdict, misjudgment_found, reviewer, findings=None, triggered_process=None, user_id="system", created_at=None): """记一次审计的审计(事后抽查纠错,留痕不改历史)。 - original_entry_id: 原结论的链 id(int >= 1)。 - original_verdict: 原结论 pass|fail。 - corrected_verdict: 纠正后结论 pass|fail;misjudgment_found 为 True 时必填(可与原结论相同,仅推理纠正时由 findings 说明);为 False 时须为 None(无判错即无纠正)。 - misjudgment_found: 严格 bool(是否发现判错)。 - reviewer: 抽查人(必填,非空 str)。 - findings: 发现清单 list(可空)。 - triggered_process: 纠正后新触发的流程,如 re-review / rebuild / none(可空 str;G6 人工验收不在此列—— 本条目没有任何代替 G6 的效力)。 校验失败抛 ValueError,绝不写半条记录。 """ _require_non_empty_str(task_id, "task_id") _require_non_empty_str(module_id, "module_id") _require_non_empty_str(contract_version, "contract_version") _require_int_ge(original_entry_id, "original_entry_id", 1) if original_verdict not in _VERDICT_VALUES: raise ValueError("original_verdict must be pass|fail") if not isinstance(misjudgment_found, bool): raise ValueError("misjudgment_found must be a bool") if misjudgment_found: if corrected_verdict not in _VERDICT_VALUES: raise ValueError( "corrected_verdict must be pass|fail when" " misjudgment_found is True") elif corrected_verdict is not None: raise ValueError( "corrected_verdict must be None when misjudgment_found" " is False") _require_non_empty_str(reviewer, "reviewer") if findings is not None and not isinstance(findings, list): raise ValueError("findings must be a list or None") if triggered_process is not None: _require_non_empty_str(triggered_process, "triggered_process") detail = json.dumps({ "module_id": module_id, "contract_version": contract_version, "original_entry_id": original_entry_id, "original_verdict": original_verdict, "corrected_verdict": corrected_verdict, "misjudgment_found": misjudgment_found, "reviewer": reviewer, "findings": findings or [], "triggered_process": triggered_process, }, sort_keys=True, ensure_ascii=False) return append_entry(conn, user_id, project_id, META_REVIEW_ACTION, detail=detail, task_id=task_id, created_at=created_at) def append_rebuild_notice(conn, task_id, project_id, module_id, contract_version, rebuild_no, notification_content, notified_party, confirmation_status="pending", confirmed_by=None, user_id="system", created_at=None): """记一次推倒重开前的用户通知与确认(未确认不得重开)。 - notification_content: 通知内容(非空 str 或非空 dict, 如新方向说明;内容本身进链,不可事后改口)。 - notified_party: 被通知方(必填,非空 str,如用户 id)。 - confirmation_status: pending|confirmed|rejected;账本 只追加,pending → confirmed 是两条记录,不是改状态。 - confirmed_by: confirmed 时必填(确认人);其它状态 须为空。 校验失败抛 ValueError,绝不写半条记录。 """ _require_non_empty_str(task_id, "task_id") _require_non_empty_str(module_id, "module_id") _require_non_empty_str(contract_version, "contract_version") _require_int_ge(rebuild_no, "rebuild_no", 1) if isinstance(notification_content, str): if not notification_content: raise ValueError( "notification_content must be non-empty") elif isinstance(notification_content, dict): if not notification_content: raise ValueError( "notification_content must be non-empty") else: raise ValueError( "notification_content must be a string or dict") _require_non_empty_str(notified_party, "notified_party") if confirmation_status not in _CONFIRM_STATUSES: raise ValueError( "confirmation_status must be pending|confirmed|rejected") if confirmation_status == "confirmed": _require_non_empty_str(confirmed_by, "confirmed_by") elif confirmed_by is not None: raise ValueError( "confirmed_by must be None unless confirmation_status" " is confirmed") detail = json.dumps({ "module_id": module_id, "contract_version": contract_version, "rebuild_no": rebuild_no, "notification_content": notification_content, "notified_party": notified_party, "confirmation_status": confirmation_status, "confirmed_by": confirmed_by, }, sort_keys=True, ensure_ascii=False) return append_entry(conn, user_id, project_id, REBUILD_NOTICE_ACTION, detail=detail, task_id=task_id, created_at=created_at) def append_wiring_event(conn, task_id, project_id, module_id, contract_version, wiring_kind, target=None, reason="", round_no=None, root_cause=None, failed_checks=None, user_id="system", created_at=None): """记一次接线兼容事件(审计加固第 1 项接线缺口新增)。 - wiring_kind: escalate | escalate_refreeze | blocked_quota | audit_sample(第 3 轨词汇,原文进链;其余第 3 轨事件 各有原生动作,不走这里,见 cloud/audit_wiring.py)。 - 本动作不参与 module_failure_counts(不是结论),时间线 只把它计入 wiring_events;门禁是否阻塞由接线层按 kind 判定(escalate / escalate_refreeze / blocked_quota 阻塞, audit_sample 不动状态)。 - root_cause 原文保存(contract 与 design 的区分只在 这里保留,audit_rebuild 行只存 contract_design)。 校验失败抛 ValueError,绝不写半条记录。 """ _require_non_empty_str(task_id, "task_id") _require_non_empty_str(module_id, "module_id") _require_non_empty_str(contract_version, "contract_version") if wiring_kind not in WIRING_KINDS: raise ValueError( "wiring_kind must be one of %s" % (WIRING_KINDS,)) if round_no is not None: _require_int_ge(round_no, "round_no", 0) if failed_checks is not None and not isinstance(failed_checks, list): raise ValueError("failed_checks must be a list or None") if target is not None: _require_non_empty_str(target, "target") if root_cause is not None: _require_non_empty_str(root_cause, "root_cause") if not isinstance(reason, str): raise ValueError("reason must be a string") detail = json.dumps({ "module_id": module_id, "contract_version": contract_version, "wiring_kind": wiring_kind, "target": target, "reason": reason, "round_no": round_no, "root_cause": root_cause, "failed_checks": failed_checks or [], }, sort_keys=True, ensure_ascii=False) return append_entry(conn, user_id, project_id, WIRING_EVENT_ACTION, detail=detail, task_id=task_id, created_at=created_at) class LedgerStore: """db_path 版账本存储(测试与脚本用;服务端走连接版函数)。 只提供追加 / 校验 / 导出 / 迁移 / 推回记录,没有修改与 删除方法——接口层面即只追加。 """ def __init__(self, db_path): from cloud.db import connect as _db_connect self.db_path = str(db_path) self.conn = _db_connect(self.db_path) ensure_schema(self.conn) self.conn.commit() def close(self): try: self.conn.close() except Exception: pass def append(self, user_id, project_id, action, detail="", task_id=None, created_at=None): """追加并提交;并发唯一冲突时在事务边界重试整条写入。""" last_exc = None for _attempt in range(5): try: entry = append_entry( self.conn, user_id, project_id, action, detail=detail, task_id=task_id, created_at=created_at) self.conn.commit() return entry except Exception as exc: if not _is_integrity_error(exc): raise last_exc = exc try: self.conn.rollback() except Exception: pass raise last_exc def verify(self, expected_head=None): return verify_chain(self.conn, expected_head=expected_head) def export(self, limit=None): return export_chain(self.conn, limit=limit) def migrate_legacy(self): result = migrate_legacy_audit_log(self.conn) self.conn.commit() return result def append_pushback_round(self, *args, **kwargs): entry = append_pushback_round(self.conn, *args, **kwargs) self.conn.commit() return entry def list_pushback_rounds(self, *args, **kwargs): return list_pushback_rounds(self.conn, *args, **kwargs) def append_audit_verdict(self, *args, **kwargs): entry = append_audit_verdict(self.conn, *args, **kwargs) self.conn.commit() return entry def append_rebuild_decision(self, *args, **kwargs): entry = append_rebuild_decision(self.conn, *args, **kwargs) self.conn.commit() return entry def list_rebuild_decisions(self, *args, **kwargs): return list_rebuild_decisions(self.conn, *args, **kwargs) def module_failure_counts(self, *args, **kwargs): return module_failure_counts(self.conn, *args, **kwargs) def audit_timeline(self, *args, **kwargs): return audit_timeline(self.conn, *args, **kwargs) def append_third_failure_assessment(self, *args, **kwargs): entry = append_third_failure_assessment( self.conn, *args, **kwargs) self.conn.commit() return entry def append_test_change_audit(self, *args, **kwargs): entry = append_test_change_audit(self.conn, *args, **kwargs) self.conn.commit() return entry def append_audit_of_audit(self, *args, **kwargs): entry = append_audit_of_audit(self.conn, *args, **kwargs) self.conn.commit() return entry def append_rebuild_notice(self, *args, **kwargs): entry = append_rebuild_notice(self.conn, *args, **kwargs) self.conn.commit() return entry def append_wiring_event(self, *args, **kwargs): entry = append_wiring_event(self.conn, *args, **kwargs) self.conn.commit() return entry