"""评分卡冻结版的服务端存储(v7 机制第 5 项:基线漂移门禁)。 定位:只存“冻结的那一版”,不算分、不判 stale、不改旧分。 算分在 cloud/audit_scorecard.py 的 build_scorecard, 漂移判定在 check_baseline_stale,本模块只做冻结行的写入与读取。 键思路沿用仓库 DB 模式:ledger.py / audit_loop_driver.py 按 (module_id, contract_version) 计 slot,本表按 (rubric_version, baseline_head) 计冻结版——同一 rubric 版本在 同一 main 头上冻结的评分卡快照,是一版;main 头推进后冻结的, 是另一版,旧版仍在表里供追溯,不被覆盖。 行字段: - rubric_version:评分口径版本(调用方传入的字符串,如 "v1"; 本模块不规定口径内容、不替用户拍板阈值)。 - baseline_head:冻结时调用方传入的 main 头(字符串,非空)。 - frozen_at:冻结时间戳(秒,调用方不传时取当前时间)。 - scorecard_snapshot:build_scorecard 输出的完整 dict,以 JSON 文本存入,读回时还原为 dict。 只追加,不改删: - 本模块只提供 ensure_schema(建表,幂等)、freeze_scorecard (INSERT 新行)、get_frozen_scorecard / get_latest_frozen_scorecard / list_frozen_scorecards(只读)。刻意不提供任何 UPDATE / DELETE 方法;SQLite 下另有触发器在表层挡住 UPDATE / DELETE (写法沿用 ledger.py 的只追加触发器)。 - 不 commit:调用方在自己的事务边界提交(与 ledger.ensure_schema 同款约定);独立调用方须自己 commit,否则同连接外看不到新行。 - 真实 PG 未接通:PG 适配连接跳过 SQLite 触发器建(方言不同, 与 ledger.py 同款处理),PG 下的只追加护栏与真实 PG 验证均未做, 本项只用 SQLite / 内存库测试并明说。 - 本模块绝不在生产代码里探测版本控制状态、绝不读版本控制目录: rubric_version / baseline_head / frozen_at 全部由调用方传入。 """ from __future__ import annotations import json import time _FROZEN_TABLE_SQL = ( "CREATE TABLE IF NOT EXISTS scorecard_baseline_freeze(" "id INTEGER PRIMARY KEY AUTOINCREMENT, " "rubric_version TEXT NOT NULL, " "baseline_head TEXT NOT NULL, " "frozen_at DOUBLE PRECISION NOT NULL, " "scorecard_snapshot TEXT NOT NULL)" ) _FROZEN_INDEX_SQL = ( "CREATE INDEX IF NOT EXISTS scorecard_baseline_freeze_key " "ON scorecard_baseline_freeze(rubric_version, baseline_head)" ) # SQLite 只追加触发器(PG 不走这段,见模块 docstring)。 _SQLITE_GUARD_SQL = ( "CREATE TRIGGER IF NOT EXISTS scorecard_baseline_freeze_no_update " "BEFORE UPDATE ON scorecard_baseline_freeze BEGIN " "SELECT RAISE(ABORT, 'scorecard_baseline_freeze is append-only'); END", "CREATE TRIGGER IF NOT EXISTS scorecard_baseline_freeze_no_delete " "BEFORE DELETE ON scorecard_baseline_freeze BEGIN " "SELECT RAISE(ABORT, 'scorecard_baseline_freeze is append-only'); END", ) def _is_pg_conn(conn): """PgConnection 适配连接(触发器方言不同,须跳过 SQLite 护栏)。""" return type(conn).__name__ == "PgConnection" def ensure_schema(conn): """建冻结表 + 索引 + SQLite 只追加触发器(幂等,不 commit)。""" conn.execute(_FROZEN_TABLE_SQL) conn.execute(_FROZEN_INDEX_SQL) if not _is_pg_conn(conn): for stmt in _SQLITE_GUARD_SQL: conn.execute(stmt) def _require_non_empty_str(value, name): if not isinstance(value, str) or value == "": raise ValueError("%s must be a non-empty string" % name) return value def freeze_scorecard(conn, rubric_version, baseline_head, scorecard_snapshot, frozen_at=None): """写入一个新冻结行(INSERT only),返回读回形态的行 dict。 scorecard_snapshot 必须是 build_scorecard 输出的 dict;不是 dict 时拒绝写入,不做任何格式猜测。调用方负责 commit。 """ _require_non_empty_str(rubric_version, "rubric_version") _require_non_empty_str(baseline_head, "baseline_head") if not isinstance(scorecard_snapshot, dict): raise ValueError("scorecard_snapshot must be a dict") stamp = float(frozen_at) if frozen_at is not None else time.time() payload = json.dumps(scorecard_snapshot, sort_keys=True, ensure_ascii=False) cur = conn.execute( "INSERT INTO scorecard_baseline_freeze" "(rubric_version, baseline_head, frozen_at, scorecard_snapshot) " "VALUES (?, ?, ?, ?)", (rubric_version, baseline_head, stamp, payload)) return { "id": cur.lastrowid, "rubric_version": rubric_version, "baseline_head": baseline_head, "frozen_at": stamp, "scorecard_snapshot": scorecard_snapshot, } def _row_to_dict(row): row_id, rubric_version, baseline_head, frozen_at, snapshot_text = row return { "id": row_id, "rubric_version": rubric_version, "baseline_head": baseline_head, "frozen_at": frozen_at, "scorecard_snapshot": json.loads(snapshot_text), } def get_frozen_scorecard(conn, rubric_version, baseline_head): """读某 (rubric_version, baseline_head) 的最新冻结行,无则 None。 同一键有多行时(不同 frozen_at 多次冻结)返回 id 最大的最新行, 旧行仍在表里,不被覆盖、不被删除。 """ _require_non_empty_str(rubric_version, "rubric_version") _require_non_empty_str(baseline_head, "baseline_head") row = conn.execute( "SELECT id, rubric_version, baseline_head, frozen_at, " "scorecard_snapshot FROM scorecard_baseline_freeze " "WHERE rubric_version = ? AND baseline_head = ? " "ORDER BY id DESC LIMIT 1", (rubric_version, baseline_head)).fetchone() return _row_to_dict(row) if row is not None else None def get_latest_frozen_scorecard(conn, rubric_version=None): """读最新冻结行;传 rubric_version 时只在该口径内取最新,无则 None。""" if rubric_version is None: row = conn.execute( "SELECT id, rubric_version, baseline_head, frozen_at, " "scorecard_snapshot FROM scorecard_baseline_freeze " "ORDER BY id DESC LIMIT 1").fetchone() else: _require_non_empty_str(rubric_version, "rubric_version") row = conn.execute( "SELECT id, rubric_version, baseline_head, frozen_at, " "scorecard_snapshot FROM scorecard_baseline_freeze " "WHERE rubric_version = ? ORDER BY id DESC LIMIT 1", (rubric_version,)).fetchone() return _row_to_dict(row) if row is not None else None def list_frozen_scorecards(conn, rubric_version=None, baseline_head=None): """按 id 升序列出冻结行;两参数均可选作过滤条件,不过滤时全列。""" sql = ("SELECT id, rubric_version, baseline_head, frozen_at, " "scorecard_snapshot FROM scorecard_baseline_freeze") clauses, params = [], [] if rubric_version is not None: _require_non_empty_str(rubric_version, "rubric_version") clauses.append("rubric_version = ?") params.append(rubric_version) if baseline_head is not None: _require_non_empty_str(baseline_head, "baseline_head") clauses.append("baseline_head = ?") params.append(baseline_head) if clauses: sql += " WHERE " + " AND ".join(clauses) sql += " ORDER BY id ASC" return [_row_to_dict(row) for row in conn.execute(sql, params).fetchall()]