diff --git a/README.md b/README.md index c90e5b8..f25bd7e 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ uv run controltrace export # 重新测试并导出 CSV、可核验底稿 ZIP uv run controltrace verify # 校验 ZIP 文件哈希,并从包内源数据重放自动测试 ``` -界面可以按系统、期间、控制类型和风险等级筛选线索;点开一条线索可查看事件时间线、关联记录、规则依据和证据 ID。复核者填写姓名、处理状态、结论与备注后,导出的 CSV 和 ZIP 会包含当前复核记录。ZIP 同时包含合成源表的原值 JSON、适合表格查看的 CSV、机器可读的复核历史 `reviews.json`、规则元数据、全部文件的 SHA-256 清单和逐条 Markdown 底稿。`verify` 会核对文件哈希、CSV 与 JSON 的一致性,并用包内原值重放规则,逐字节比较所选发现清单与底稿。格式 2/3 继续按其历史规则核验,更早格式请使用原版本。 +界面可以按系统、期间、控制类型和风险等级筛选线索;点开一条线索可查看事件时间线、关联记录、规则依据和证据 ID。复核者填写姓名、处理状态、结论与备注后,导出的 CSV 和 ZIP 会包含当前复核记录。ZIP 同时包含合成源表的原值 JSON、适合表格查看的 CSV、机器可读的复核历史 `reviews.json`、规则元数据、全部文件的 SHA-256 清单和逐条 Markdown 底稿。`verify` 会核对文件哈希、CSV 与 JSON 的一致性,并用包内原值重放规则,逐字节比较所选发现清单与底稿。格式 2–4 继续按其历史规则核验,更早格式请使用原版本。 底稿 JSON 会先经过结构校验:清单必须是对象、源表必须是对象行数组,重复键与 `NaN`/`Infinity` 等非有限数值会被拒绝。非法输入由 `verify` 返回错误说明和退出码 1,避免把解析歧义带入后续重放。 @@ -70,7 +70,7 @@ uv run controltrace verify # 校验 ZIP 文件哈希,并从包内源数据 **复核不能只跟着发现 ID 走。** 0.2.8 将每次新复核绑定到自动发现的完整证据指纹。相同证据重跑保留当前结论;同一 ID 的关联来源记录或判断口径变化后,原结论保留在历史中,当前状态返回待复核。界面保存时核对首次展示的编辑版本;其他人追加复核或证据发生变化后,须显式重新加载,防止旧表单覆盖新证据。版本检查、复核及指纹关联在一个事务内提交,并对同一发现的并发写入进行冲突检查。[复核完整性设计](docs/REVIEW_INTEGRITY.md)解释业务场景、Python API、迁移和可复现实验。 -新 ZIP 使用格式 4,延续格式 3 的 `review_evidence.json`,`verify` 根据重放的当前证据选择可适用的复核。0.2.9 加强 CSV 字符串和表头的公式防护:全角触发符、前置空白与控制字符使用可见 `text: ` 前缀,JSON 原值不变。格式 3 / 0.2.8 保留旧 CSV 规则,格式 2 / 0.2.7 也仍可核验,后者明确说明没有证据版本关联。旧数据库的未知历史仍须重新评估,不补造证据关联;新包没有归档历史源快照,也不提供身份认证或签名。[CSV 策略与版本兼容表](docs/CSV_EXPORTS.md)。 +新 ZIP 使用格式 5,延续 `review_evidence.json` 和可见 `text: ` CSV 策略,按解析后的 UTC 时刻展示时间线,保留原始时间字符串。0.2.10 修复小数秒、混合时区被字符串顺序颠倒的问题。旧格式 2–4 继续按其原顺序重放,校验器明确提示不保证 UTC 时间顺序;格式 2 还明确说明没有证据版本关联。排序修正会改变发现的完整指纹,旧复核保留为历史,当前结论须按新指纹重新评估。新包不补造未知历史关联,不归档历史源快照,也不提供身份认证或签名。[时间线与复核迁移](docs/TIMELINE_ORDER.md) · [CSV 兼容表](docs/CSV_EXPORTS.md)。 ## 数据字典与案例 @@ -91,7 +91,7 @@ python -m ruff check src tests python -m pytest -q ``` -**本机验证(2026-10-24,Linux / Python 3.12.14):** 完整 pytest 为 **108 passed**,Ruff 与 git diff --check 通过。新增 18 个 CSV/兼容回归,覆盖全角及控制字符、表头、前缀碰撞、数值、多行记录边界、新格式原值保留,以及由未修改的 0.2.8 实际导出的旧包重放。该旧包含 12 条预置线索中的一条导出、28 个源文件和一条程序生成的合成复核;不是实际人员的审计意见。既有用例覆盖证据变更后重新待复核、并发复核、事务回滚、迁移、非法 JSON 和重哈希后的伪造结论。[GitHub Actions CI](https://github.com/dev-belly/ControlTrace/actions/workflows/ci.yml) 在 Ubuntu / Python 3.11、3.12 和 Windows / Python 3.12 运行 Ruff 与完整 pytest。当前包版本为 0.2.9,新导出采用格式 4;格式 2/3 保持其历史重放策略。 +**本机验证(2026-10-24,Linux / Python 3.12.14):** 完整 pytest 为 **113 passed**,Ruff、git diff --check 与锁文件检查通过。18 个 CSV/兼容回归覆盖全角和控制字符、表头、前缀碰撞、数值、多行记录边界及原值保留;另有 5 个时间线/历史绑定回归,覆盖小数秒、混合时区、无效展示时间、旧顺序提示和旧结论不能继承。保留未修改的 0.2.8、0.2.9 代码实际生成的合成底稿,不以当前序列化器伪造旧包。既有用例覆盖并发复核、事务回滚、迁移、非法 JSON 和重哈希后的伪造结论。[GitHub Actions CI](https://github.com/dev-belly/ControlTrace/actions/workflows/ci.yml) 在 Ubuntu / Python 3.11、3.12 和 Windows / Python 3.12 运行 Ruff 与完整 pytest。当前包版本为 0.2.10,新导出采用格式 5;格式 2–4 保持其原有 CSV 与时间线重放策略。 ## 已知局限 diff --git a/docs/CSV_EXPORTS.md b/docs/CSV_EXPORTS.md index 2418791..b5915e6 100644 --- a/docs/CSV_EXPORTS.md +++ b/docs/CSV_EXPORTS.md @@ -21,9 +21,11 @@ CSV 是便于表格查看的副本。`source_tables/*.json` 与 `reviews.json` | 2 / 0.2.7 | 原单引号策略 | 无,明确返回 `review_evidence_bound=false` | | 3 / 0.2.8 | 原单引号策略 | 有 | | 4 / 0.2.9 | 可见 `text: ` 策略,包含表头 | 有 | +| 5 / 0.2.10 | 同格式 4,时间线改用实际 UTC 顺序 | 有 | 格式号与应用版本必须匹配;校验器不能把旧 CSV 当成新策略处理。格式 2/3 -只在校验旧底稿时使用历史序列化规则,新导出始终使用格式 4。 +只在校验旧底稿时使用历史序列化规则,新导出始终使用格式 5。 +格式 2–4 仍按原字符串时间线重放,并明确报告旧排序;见[时间线与复核迁移](TIMELINE_ORDER.md)。 保留旧包原文件,再用当前源快照重新导出,可得到采用新 CSV 策略的副本; 不要手改旧包的清单、版本号或哈希。新导出不能补造未知的历史证据版本。 diff --git a/docs/REVIEW_INTEGRITY.md b/docs/REVIEW_INTEGRITY.md index ad6b2d2..ac50c7b 100644 --- a/docs/REVIEW_INTEGRITY.md +++ b/docs/REVIEW_INTEGRITY.md @@ -42,7 +42,7 @@ save_review( 从格式 3 起,底稿保留原 `reviews.json` 字段,同时增加 `review_evidence.json`:按发现和复核 ID 保存关联指纹,未知历史为 `null`。CSV 标明当前证据指纹和不适用历史数量,Markdown 明确显示当前结论与全部历史。校验器从包内源记录重放自动发现,重算指纹并独立选择当前结论;即使 CSV、底稿和文件哈希被一致修改,旧证据结论冒充当前结论仍会被拒绝。0.2.9 新导出采用格式 4,加强 CSV 字符串和表头的展示策略,JSON 原值与证据关联规则不变;[CSV 版本表](CSV_EXPORTS.md)列出细节。 -格式 2、版本 0.2.7 的旧底稿继续支持原始重放;返回 `review_evidence_bound=false`,命令行也说明旧格式没有证据版本关联。格式 3 / 0.2.8 保留原 CSV 策略及证据关联重放。更早版本仍需其原版本校验器。这种兼容不会把旧底稿升级成拥有新保证的证据。 +格式 2、版本 0.2.7 的旧底稿继续支持原始重放;返回 `review_evidence_bound=false`,命令行也说明旧格式没有证据版本关联。格式 3 / 0.2.8 保留原 CSV 策略及证据关联重放,格式 4 / 0.2.9 保留其可见文本 CSV。0.2.10 新导出使用格式 5,按实际 UTC 时刻排列时间线;格式 2–4 继续按原字符串顺序重放,并返回 `legacy_timeline_order=true`。更早版本仍需其原版本校验器。这种兼容不会把旧底稿升级成拥有新保证的证据。[时间线迁移](TIMELINE_ORDER.md)说明为什么排序修正也可能要求重新复核。 指纹和包内清单不能证明复核者身份、源系统真实性或防御同时重写所有内容的攻击者。新包只带当前源快照,保留的历史指纹并不等于保存了旧源快照。认证、签名和历史源版本归档仍是实际业务接入前需要单独解决的工作。 diff --git a/docs/TIMELINE_ORDER.md b/docs/TIMELINE_ORDER.md new file mode 100644 index 0000000..260847f --- /dev/null +++ b/docs/TIMELINE_ORDER.md @@ -0,0 +1,31 @@ +# 审计时间线按实际 UTC 时刻排序 + +ISO 时间字符串的字典顺序不一定是事件顺序:`11:00:00+01:00` 是 UTC +`10:00:00`,而 `06:00:02-04:00` 是 UTC `10:00:02`。旧展示会把后者排在 +前面。整秒的 `...00Z` 与小数秒的 `...00.050000Z` 也不能直接按字符排序。 + +0.2.10 根据解析后的 UTC 时刻排序,以证据 ID 和字段名打破同时刻的并列。 +时间线上仍显示原始来源字符串;不能解析或为空的时间不加入时间线,但原记录 +继续保留在关联证据中。控制谓词未改变,未知或非法业务输入仍须单独核实。 + +## 为什么需要格式 5 + +发现指纹包含完整时间线。若修正顺序,旧指纹就不再表示当前自动发现,之前的 +复核保留为历史,当前状态返回 `pending`;不能因为来源 ID 没变就继承旧结论。 +这里可能只改变了证据呈现的顺序,并不意味着来源时间被改写。 + +| 底稿格式 / 版本 | 时间线重放 | CSV 策略 | 证据关联 | +| --- | --- | --- | --- | +| 2 / 0.2.7 | 原字符串顺序 | 原单引号方式 | 无 | +| 3 / 0.2.8 | 原字符串顺序 | 原单引号方式 | 有 | +| 4 / 0.2.9 | 原字符串顺序 | 可见 `text: ` | 有 | +| 5 / 0.2.10 | 实际 UTC 时刻 | 可见 `text: ` | 有 | + +格式 2–4 校验时重建原顺序,以保留原底稿与复核指纹的一致性,返回 +`legacy_timeline_order=true`,命令行明确提醒它不保证 UTC 时间顺序。 +新导出始终使用格式 5,返回 `legacy_timeline_order=false`。不要直接修改旧包 +的格式号或清单来冒充升级;保留原包,再用当前源快照重跑并按新指纹重新复核。 + +[0.2.9 实际导出包](../tests/fixtures/README.md)保留混合时区的旧顺序和一条 +程序生成的合成复核。回归同时验证旧包可核验,以及当前排序不会继承旧指纹 +的关闭结论。这些是合成工程测试,不是实际审计意见或来源真实性证明。 diff --git a/pyproject.toml b/pyproject.toml index c071b9c..d93cc1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "controltrace" -version = "0.2.9" +version = "0.2.10" description = "Synthetic IT audit evidence and internal control testing workbench" readme = "README.md" requires-python = ">=3.11" diff --git a/src/controltrace/__init__.py b/src/controltrace/__init__.py index f209780..f3759d1 100644 --- a/src/controltrace/__init__.py +++ b/src/controltrace/__init__.py @@ -1,3 +1,3 @@ """ControlTrace synthetic IT audit demonstration.""" -__version__ = "0.2.9" +__version__ = "0.2.10" diff --git a/src/controltrace/cli.py b/src/controltrace/cli.py index d429eaa..928a93e 100644 --- a/src/controltrace/cli.py +++ b/src/controltrace/cli.py @@ -45,6 +45,8 @@ def main(argv: list[str] | None = None) -> int: ) if not result["review_evidence_bound"]: print("Legacy bundle: review history is valid but was not bound to an evidence snapshot.") + if result["legacy_timeline_order"]: + print("Legacy timeline: original string order is replayed; UTC chronology is not guaranteed.") return 0 from controltrace.rules import run_tests from controltrace.store import initialize_db diff --git a/src/controltrace/exports.py b/src/controltrace/exports.py index 1077593..de89726 100644 --- a/src/controltrace/exports.py +++ b/src/controltrace/exports.py @@ -217,7 +217,7 @@ def workpaper_zip(db_path: str | Path, finding_ids: set[str] | None = None) -> b }) manifest = { "notice": "Synthetic case study; no real enterprise audit data", - "bundle_format_version": 4, + "bundle_format_version": 5, "audit_cutoff": DEMO_CUTOFF, "generator_seed": int(meta.get("seed", DEFAULT_SEED)), "controltrace_version": __version__, diff --git a/src/controltrace/rules.py b/src/controltrace/rules.py index 4f4135a..fecc550 100644 --- a/src/controltrace/rules.py +++ b/src/controltrace/rules.py @@ -109,6 +109,32 @@ def _index(rows: list[dict], field: str) -> dict[str, dict]: return {row[field]: row for row in rows} +def evidence_timeline(related: list[dict], *, legacy: bool = False) -> list[dict]: + """Preserve source timestamps while ordering the current view by UTC.""" + timeline = [] + for item in related: + record = item["record"] + for field, label, kind in _TIMELINE_FIELDS.get(item["table"], []): + value = record.get(field) + if legacy: + include = bool(value) + else: + try: + include = parse_utc(value) is not None + except (ValueError, TypeError, AttributeError, OverflowError): + include = False + if include: + timeline.append({ + "at": value, "event": label, "kind": kind, + "field": field, "evidence_id": item["id"], + "source_system": record["source_system"], + }) + timeline.sort(key=lambda item: ( + item["at"] if legacy else parse_utc(item["at"]), item["evidence_id"], item["field"], + )) + return timeline + + def _finding( control_id: str, issue_code: str, @@ -132,17 +158,7 @@ def _finding( {"table": table, "id": str(record[PRIMARY_KEYS[table]]), "record": record.copy()} for table, record in unique.values() ] - timeline = [] - for item in related: - record = item["record"] - for field, label, kind in _TIMELINE_FIELDS.get(item["table"], []): - if record.get(field): - timeline.append({ - "at": record[field], "event": label, "kind": kind, - "field": field, "evidence_id": item["id"], - "source_system": record["source_system"], - }) - timeline.sort(key=lambda item: (item["at"], item["evidence_id"], item["field"])) + timeline = evidence_timeline(related) digest = hashlib.sha256(f"{control_id}|{issue_code}|{entity_id}".encode()).hexdigest()[:12] return { "finding_id": f"F-{digest.upper()}", diff --git a/src/controltrace/verify.py b/src/controltrace/verify.py index bef0b02..8576e44 100644 --- a/src/controltrace/verify.py +++ b/src/controltrace/verify.py @@ -16,7 +16,7 @@ from controltrace.data import DEMO_CUTOFF, SOURCE_TABLES from controltrace.exports import _csv_bytes, _json, exception_csv, finding_workpaper from controltrace.reviews import REVIEW_FIELDS, bind_review_history, validate_review_decision -from controltrace.rules import RULES, evaluate +from controltrace.rules import RULES, evaluate, evidence_timeline class BundleVerificationError(ValueError): @@ -61,10 +61,11 @@ def verify_bundle(path: str | Path) -> dict[str, Any]: manifest = _read_json(archive, "manifest.json") _require(isinstance(manifest, dict), "Manifest must be a JSON object") bundle_format = manifest.get("bundle_format_version") - _require(bundle_format in (2, 3, 4), + _require(bundle_format in (2, 3, 4, 5), "Unsupported bundle format; export again with this ControlTrace version") evidence_bound = bundle_format >= 3 legacy_csv = bundle_format < 4 + legacy_timeline = bundle_format < 5 expected_files = manifest.get("files_sha256") _require(isinstance(expected_files, dict), "Manifest has no complete file hash list") _require(set(expected_files) == set(names) - {"manifest.json"}, @@ -88,7 +89,7 @@ def verify_bundle(path: str | Path) -> dict[str, Any]: _require(manifest.get("audit_cutoff") == DEMO_CUTOFF, "Bundle cutoff does not match the current control rules") bundle_version = manifest.get("controltrace_version") - _require(bundle_version == {2: "0.2.7", 3: "0.2.8", 4: __version__}[bundle_format], + _require(bundle_version == {2: "0.2.7", 3: "0.2.8", 4: "0.2.9", 5: __version__}[bundle_format], "Bundle was created by a different ControlTrace version") _require(_read_json(archive, "rules.json") == RULES, "Rule catalog differs from the installed ControlTrace version") @@ -105,6 +106,11 @@ def verify_bundle(path: str | Path) -> dict[str, Any]: source_rows[table] = rows data = {table: rows for table, rows in source_rows.items() if table != "expected_results"} replayed = evaluate(data) + if legacy_timeline: + # Old evidence fingerprints include the original timeline order. + # Replay those retained bytes without granting UTC-order guarantees. + for finding in replayed: + finding["timeline"] = evidence_timeline(finding["related_records"], legacy=True) all_ids = [finding["finding_id"] for finding in replayed] _require(len(all_ids) == len(set(all_ids)), "Replay produced duplicate finding IDs") _require(manifest.get("all_finding_ids") == all_ids, @@ -191,4 +197,5 @@ def verify_bundle(path: str | Path) -> dict[str, Any]: "exported_findings": len(selected_ids), "scope": scope, "review_evidence_bound": evidence_bound, + "legacy_timeline_order": legacy_timeline, } diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index a3f7135..c1f39c2 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -9,3 +9,12 @@ created test review, not a real person's audit decision. The review note contain an ASCII formula prefix, embedded CR/LF and fullwidth text. Its CSV uses the historical apostrophe policy. The regression must replay these retained bytes under the format-3 policy, while new format-4 exports use visible `text: ` prefixes. + +`controltrace-0.2.9-v4-timeline.zip` was exported by the unmodified 0.2.9 code at +`20c0bcfe33e23a95a3c0f95975d86474800fcd26` before changing timeline ordering. +SHA-256: `85401d33aee4c20b81469d58b505d07658a1f3c8c0b57c27a184b6906ea069c9`. +Three synthetic CH002/D002 timestamps use mixed UTC offsets, and one test review +is programmatically recorded. Original lexical order places deployment before +approval. Format-4 replay must preserve its historical bytes and review binding, +with an explicit legacy-order notice. A current UTC-order replay must require +a new review for the corrected finding representation. diff --git a/tests/fixtures/controltrace-0.2.9-v4-timeline.zip b/tests/fixtures/controltrace-0.2.9-v4-timeline.zip new file mode 100644 index 0000000..a8f532a Binary files /dev/null and b/tests/fixtures/controltrace-0.2.9-v4-timeline.zip differ diff --git a/tests/test_csv_policy.py b/tests/test_csv_policy.py index 7ccb0ad..0f44576 100644 --- a/tests/test_csv_policy.py +++ b/tests/test_csv_policy.py @@ -43,7 +43,7 @@ def test_visible_prefix_does_not_merge_distinct_strings(): ] -def test_v4_review_export_keeps_original_json_and_replays(tmp_path): +def test_current_review_export_keeps_original_json_and_replays(tmp_path): db = tmp_path / "case.duckdb" initialize_db(db) finding_id = run_tests(db)[0]["finding_id"] @@ -58,8 +58,8 @@ def test_v4_review_export_keeps_original_json_and_replays(tmp_path): assert verify_bundle(path)["review_evidence_bound"] is True with zipfile.ZipFile(io.BytesIO(bundle)) as archive: manifest = json.loads(archive.read("manifest.json")) - assert manifest["bundle_format_version"] == 4 - assert manifest["controltrace_version"] == "0.2.9" + assert manifest["bundle_format_version"] == 5 + assert manifest["controltrace_version"] == "0.2.10" assert json.loads(archive.read("reviews.json"))[finding_id][0]["notes"] == notes diff --git a/tests/test_legacy_timeline.py b/tests/test_legacy_timeline.py new file mode 100644 index 0000000..d2443ae --- /dev/null +++ b/tests/test_legacy_timeline.py @@ -0,0 +1,38 @@ +"""A timeline fix must not reinterpret historical review fingerprints.""" + +import json +import zipfile +from pathlib import Path + +from controltrace.cli import main +from controltrace.data import SOURCE_TABLES, parse_utc +from controltrace.reviews import bind_review_history +from controltrace.rules import evaluate +from controltrace.verify import verify_bundle + +FIXTURE = Path(__file__).parent / "fixtures" / "controltrace-0.2.9-v4-timeline.zip" + + +def test_actual_v029_archive_replays_with_an_explicit_legacy_timeline_notice(capsys): + result = verify_bundle(FIXTURE) + assert result["legacy_timeline_order"] is True + assert result["review_evidence_bound"] is True + assert result["exported_findings"] == 1 + assert main(["verify", "--bundle", str(FIXTURE)]) == 0 + assert "UTC chronology is not guaranteed" in capsys.readouterr().out + + +def test_corrected_timeline_does_not_inherit_an_old_order_review(): + with zipfile.ZipFile(FIXTURE) as archive: + data = {table: json.loads(archive.read(f"source_tables/{table}.json")) for table in SOURCE_TABLES} + manifest = json.loads(archive.read("manifest.json")) + finding_id = manifest["finding_ids"][0] + history = json.loads(archive.read("reviews.json"))[finding_id] + bindings = json.loads(archive.read("review_evidence.json"))[finding_id] + finding = next(item for item in evaluate(data) if item["finding_id"] == finding_id) + instants = [parse_utc(item["at"]) for item in finding["timeline"]] + assert instants == sorted(instants) + rebound = bind_review_history(finding, history, bindings) + assert rebound["review_status"] == "pending" + assert rebound["latest_review"] is None + assert rebound["review_stale_count"] == 1 diff --git a/tests/test_rules.py b/tests/test_rules.py index c9a5fb9..ba825a1 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -7,7 +7,7 @@ import pytest from controltrace.data import generate_demo_data, parse_utc -from controltrace.rules import evaluate +from controltrace.rules import evaluate, evidence_timeline def _record(data, table, key, value): @@ -43,6 +43,36 @@ def test_fixture_manifest_matches_actual_rule_outcomes(): assert finding["rule"]["control_goal"] +@pytest.mark.parametrize( + ("approved_at", "tested_at", "deployed_at"), + [ + ("2025-06-10T10:00:00Z", "2025-06-10T10:00:00.050000Z", "2025-06-10T10:00:00.100000Z"), + ("2025-06-10T11:00:00+01:00", "2025-06-10T10:00:01Z", "2025-06-10T06:00:02-04:00"), + ], +) +def test_evidence_timeline_orders_actual_instants_and_preserves_source_times( + approved_at, tested_at, deployed_at +): + data = deepcopy(generate_demo_data()) + deployment = _record(data, "deployments", "deployment_id", "D002") + deployment["deployed_at"] = deployed_at + ticket = _record(data, "change_tickets", "ticket_id", deployment["ticket_id"]) + ticket.update(approved_at=approved_at, tested_at=tested_at) + finding = next(item for item in evaluate(data) if item["entity_id"] == "D002") + timeline = finding["timeline"] + instants = [parse_utc(item["at"]) for item in timeline] + assert instants == sorted(instants) + assert [item["at"] for item in timeline[:3]] == [approved_at, tested_at, deployed_at] + + +def test_timeline_omits_unparseable_view_entries_without_rewriting_source(): + record = {"submitted_at": "", "approved_at": "not-a-time", "tested_at": "2025-06-10T10:00:00Z", + "source_system": "synthetic"} + related = [{"table": "change_tickets", "id": "test", "record": record}] + assert [item["field"] for item in evidence_timeline(related)] == ["tested_at"] + assert record["approved_at"] == "not-a-time" + + def test_exact_deadline_is_on_time_for_each_time_based_control(): data = deepcopy(generate_demo_data()) _record(data, "accounts", "account_id", "A001")["disabled_at"] = "2025-06-12T09:00:00Z" diff --git a/uv.lock b/uv.lock index 7bb6070..139822d 100644 --- a/uv.lock +++ b/uv.lock @@ -220,7 +220,7 @@ wheels = [ [[package]] name = "controltrace" -version = "0.2.9" +version = "0.2.10" source = { editable = "." } dependencies = [ { name = "duckdb" },