feat(kernel): hash-linked event chain with anchored doctor gate (v0.10.0, slice 1/5) - #82
Conversation
…d feature-detected row reader with typed decode errors Task 3: - fresh-store DDL gains `prev_event_hash TEXT` + `event_hash TEXT NOT NULL` (design change D1: a pre-0.10.0 10-column INSERT now fails closed). The DDL is CREATE TABLE IF NOT EXISTS, so legacy tables are untouched — _connect() never upgrades a legacy store. - fresh stores get `PRAGMA user_version = 2`, set only when this connect actually created the events table (probed via sqlite_master beforehand). - EventRowDecodeError(ValueError) for an undecodable payload/artifact_hashes. - has_chain_columns(), store_user_version(), _BASE_COLUMNS, read_event_rows() — the single feature-detecting row projection shared by store, runtime and runner reads. Records always carry prev_event_hash/event_hash keys; legacy rows project None. It owns the JSON-decode translation each call site used to repeat. - read() rewritten onto read_event_rows(). - scripts/chain_fixtures.py: shared byte-faithful v0.9.0 legacy-store builder (test-only; the wheel force-includes scripts individually, so it does not ship). Folded in from Task 4 Step 3 — moved from Task 4 per controller end-green adjudication, and nothing else from Task 4: - append() computes prev_event_hash/event_hash via loop.chain on chained stores and writes a 12-column INSERT; legacy stores keep the v0.9.0 10-column INSERT and project both fields as None. - an unhashable record (ChainHashError) rolls back and raises EventValidationError. Why folded: `event_hash NOT NULL` is *defined* as the constraint that rejects a 10-column writer, and until Task 4 append() was itself a 10-column writer — so the DDL cannot land before the chained writer without breaking the plan's end-green invariant. Measured: the 2 DDL lines alone took the suite from 951 passed to 828 passed / 123 failed across 11 files. Still Task 4: EventStoreOperationalError, the sqlite3.OperationalError wrap, runcontrol/runner wiring, and all of Task 4's tests. Reviewed edits: NONE required. No existing test pinned the returned/read record key-set or the PRAGMA table_info column list, so the two new record keys and two new columns broke nothing. Gates: focused (test_event_chain + test_eventstore) green; full suite 955 passed / 14 skipped (was 951/14 — exactly the 4 new tests, no regressions); pyyaml-only fallback 886 passed / 83 skipped (skip count unchanged, so the new tests are unconditional in both validation modes).
…error with a handled CLI path The chain computation inside append() itself landed in 4d96499 (Task 3) per controller adjudication; this commit adds its behavioral tests plus the typed error surface that was still missing. - EventStoreOperationalError(RuntimeError): the events table exists but cannot service the operation (schema drift, lock). append() wraps its transaction body so a drifted or locked store never leaks a raw sqlite3.OperationalError. - Handled CLI path: runcontrol._append_event and a new runner._store_append translate it into RuntimeStoreError("event_store_unusable", ...), which __main__ already catches for run/pause/resume/approve/cancel -> exit 2 with no traceback. - Tests pin store-computed chaining on fresh stores, caller-supplied chain fields being ignored, legacy stores staying unchained, the NOT NULL refusal of a pre-0.10.0 ten-column INSERT, both translation sites, and a subprocess probe that `loop run` / `loop pause` on a drifted store exit 2 traceback-free.
loop/migrate.py + `loop migrate <workspace>`: the only path that upgrades a
v0.9.0 legacy events.db to the chained v2 shape. Connect still never upgrades.
Migration widens the table (nullable prev_event_hash/event_hash) and stamps
user_version=2; it never rewrites rows — backfilling hashes is deliberately
impossible because the append-only triggers forbid UPDATE. Pre-migration rows
stay an unchained prefix and the first post-migration append is a chain genesis.
Typed fail-loud: missing store -> RuntimeStoreError("missing_store"), corrupt
store -> RuntimeStoreError("corrupt_store"); the CLI prints `migrate: <code>: ...`
to stderr and exits 2 with no traceback.
Reviewed edits (outside the brief's create-only file list):
- scripts/test_loop_cli.py: added "migrate" to the command tuple in
test_help_lists_every_command_with_a_description. That test is the only
help/usage-parity assertion enumerating commands; leaving it unchanged would
let the new verb drift out of the documented surface unchecked. Required by
the task brief (Step 5) to land in this same commit.
chain.link_issue() runs per event inside _reduce_one, immediately after the non-monotonic-sequence check — the single chokepoint every folding verb (status/replay/run/simulate/doctor) passes through. A violation raises ChainBreakError(EventReplayError); the projection gains chain_head and unchained_prefix so verify_chain and the reducer report the same two numbers. Legacy/hand-built streams carry no event_hash, so link_issue returns None and they fold unchanged: verified by running, not assumed (test_reducer.py, test_adversarial_kernel.py 300-example property walks, test_adversarial_process.py all green). The raw-byte SQLite boundary pin is untouched — it asserts SQLiteEventStore.read() does not detect the tamper, which stays true; the chain adds detection one layer up, at the fold. No reviewed edits: zero existing tests required modification. Full suite +7 passed in both modes, zero regressions (extras 981/14 -> 988/14; pyyaml-only 911/84 -> 918/84).
…ce event validation; race-safe immutable reads leave no sidecars Shared read path: new loop/runtime._read_only_connect (immutable=1 when no -wal sidecar exists, plain mode=ro otherwise) plus _read_store, which retries a failed read once as plain mode=ro so a lost race with a live append cannot mint a false corrupt_store (design change D4). Used by _read_events_readonly, _discover_run_id and runner._projection; read verbs now leave no -wal/-shm residue on a clean store (the PR #77 H4b finding). Row projection delegates to events.read_event_rows; EventRowDecodeError maps to RuntimeStoreError('corrupt_store', ...) at both call sites (design change D5), preserving the no-traceback invariant for in-row JSON corruption. runtime._events now enforces the per-event validation verdict it used to discard: a failing event raises RuntimeStoreError('invalid_event', ...) and the empty stream raises 'empty_store', replacing the 'assert validation is not None' that evaporates under python -O (decision 9a). All four folding surfaces report one code for a broken chain: status_report and replay_report catch ChainBreakError before EventReplayError, and runner._projection maps it before its generic except ValueError so run/simulate/run-control no longer relabel it invalid_event_stream (design change D3). status/replay reports gain chain_head + unchained_prefix (read via .get so the degraded projection cannot KeyError); doctor nests {'chain': {'head', 'unchained_prefix'}} under event_store. Reviewed edits: - scripts/test_doctor_eventstore.py test_synced_happy_path_is_doctor_clean gains two assertions on the new event_store['chain'] block (deliberate per Task 8 Step 4; the absent-store byte-stability pin is untouched and still passes). - loop/runner.py drops the now-unused EVENT_SCHEMA_ID import and its hand-rolled immutable-URI comment, both superseded by read_event_rows/_read_only_connect. Tests: 998 passed / 14 skipped with [schemas,yaml] extras (baseline 988/14); 928 passed / 84 skipped pyyaml-only (baseline 918/84). Delta is exactly the 10 new tests in both lanes, no skip movement.
…code-error regression lock Finding 1: runner._projection used _read_only_connect directly, so a first-query sqlite3.DatabaseError became corrupt_store with no plain mode=ro reopen — the exact false alarm D4 exists to prevent, and simulate is a read-only monitoring verb that can race a live appender. Its two queries are now one read_stream closure passed to the shared runtime._read_store, giving run/simulate/run-control identical two-stage semantics to status/replay/doctor. Real corruption still fails both attempts and still maps to corrupt_store; the empty_store/ambiguous_run_id raises inside the closure are not DatabaseError and so are not retried. Shares the helper rather than duplicating the retry (imports _read_store in place of _read_only_connect, which is now reached through it). Finding 2: the runner EventRowDecodeError -> corrupt_store clause had no coverage. Adds scripts/test_doctor_eventstore.py::test_run_on_in_row_json_corruption_reports_ corrupt_store (same drop-trigger + corrupt-payload fixture as the doctor-side test, driven through dispatch_once). Negative control run: with the clause deleted the test fails with a bare loop.events.EventRowDecodeError escaping dispatch_once, outside the typed RuntimeStoreError family. Also adds scripts/test_event_chain.py::test_runner_read_path_retries_plain_mode_ro_ before_declaring_corruption, which pins Finding 1 falsifiably: it counts the sqlite3.connect calls a dispatch over a schema-drifted store makes and asserts exactly two (immutable first, plain mode=ro second, both mode=ro). It was red before the fix (1 == 2). Tests: 1000 passed / 14 skipped extras (was 998/14); 930 passed / 84 skipped pyyaml-only (was 928/84). +2 in both lanes = the two new tests, no skip movement.
… absent-store sidecar tripwire
event_consistency_issues gains expect_chain_head. The anchor fails hard on all
four ways a store can fail to prove the expected head: absent, unreadable,
unchained/broken (chain_head degrades to None), or diverged. A tampered store
therefore cannot pass an anchored doctor even though its chain block is
byte-identical to an unchained one.
Absent-store branch also trips on -wal/-shm residue left behind by a deleted
events.db (missing_event_store). With no store, no sidecars and no anchor the
event_store block stays exactly {"present": False}.
Readable branch cross-checks the lazy downgrade: user_version >= 2 with the
chain columns dropped (chain_columns_missing).
Reviewed edits vs the brief:
- _store_declares_chain_without_columns routes its PRAGMA probe through
_read_store, not a bare _read_only_connect, so a lost immutable=1 race
retries plainly instead of being misreported as corruption (the D4 hole);
it runs inside the existing try so a genuinely unreadable store surfaces as
the typed unreadable branch rather than escaping doctor_report.
- shared _anchor_mismatch helper for the three anchor issue sites.
- added test_expect_chain_head_on_tampered_store_fails_doctor pinning the
broken-chain composition end to end.
…ecompute, truncation, downgrade, legacy) Eight integration tests over doctor's chain predicate (issue codes + the event_store.chain block, never global ok — design change D6). Detected: single-row splice with local recompute (the successor still cites the original), payload reorder across two same-type rows, and a mid-stream event_hash strip (the unchained-after-chained-prefix branch, until now only unit-pinned in loop/chain.py's own tests). Pinned honest limitations — these assert the attack SUCCEEDS, and keep reference/repo-os-contract.md #16 honest; if one starts failing the kernel gained a property and #16 must be updated in the same commit: - full history rewrite with a genesis re-chain plus a forged state.json and terminal_state.json (a FailedBlocked run laundered into Succeeded leaves doctor globally ok with zero issues), - trailing receipt_appended truncation (state-neutral, chain stays valid), - column-drop downgrade combined with PRAGMA user_version = 0 (defeats the D2 cross-check; without the PRAGMA reset chain_columns_missing still fires), - tamper on a never-migrated store (no retroactive coverage). In every one, --expect-chain-head is the control that catches it. Also pins the fourth fail-hard anchor shape: an anchor over a never-chained store fails rather than skipping. Reviewed edit outside the new file: scripts/test_doctor_eventstore.py's test_chain_columns_dropped_but_version_2_fails_doctor gains the same sqlite_version_info < (3, 35) skipif as the two DROP COLUMN tests here — on a contributor machine with older SQLite it would ERROR rather than skip. Tests only; loop/ and schemas/ byte-unchanged.
…nest limitation test_full_rewrite_with_recompute_passes_without_anchor_pinned asserted only the ABSENCE of named issue codes, so a future kernel that caught the full rewrite under a NEW code would have left the pin green — and the pin's headline claim (a FailedBlocked run laundered into Succeeded leaves doctor's event-store layer reporting nothing at all) rested on an out-of-tree probe rather than the test. Adds three positive assertions on the unanchored report's own event_store block: state_json_agrees / deterministic / legal_sequence are each True. Those are event-store-scoped predicates emitted by runtime.event_consistency_issues, so any new event-store-layer detection flips them, while unrelated validate_contract noise cannot. Global report["ok"] is deliberately still not asserted (design change D6). Verified they have teeth: on the same workspace with the history rewritten but the chain left unrepaired, state_json_agrees and legal_sequence both read False. Assertion-only change to one existing test: 1019 passed / 14 skipped with extras, unchanged from the previous commit.
…enerations, zero carve-out on clean stores
…ce it as an anchor
…, integrity boundary, anchor trust assumptions
… locks, dedup
1. test_event_chain.py: delete the duplicated drifted-store CLI test
(test_migrate_cli.py keeps the copy); drop the now-unused subprocess/sys
imports and the duplicate `from loop.runtime import RuntimeStoreError`.
2. test_documented_conformance_vectors: also assert each vector's canonical
preimage string appears literally in the contract. Verified first that all
three published Preimage lines already are canonical_json output — no doc
line needed regenerating.
3. test_cli_rejects_malformed_anchor_value: capture stderr and assert the
"must be a 64-character lowercase hex sha256" message, so the test fails on
a tree where the flag is swallowed as a positional target.
4. test_legacy_store_tamper_is_undetectable_pinned: staging self-guard — read
the payload back and assert the tamper landed before the non-detection
assertions.
5. New test_unhashable_record_breaks_chain: a bare NaN token in a payload
(json.loads parses it, canonical_json refuses it) must surface as
event_chain_broken, not a propagated ChainHashError. Required payload fields
are kept intact because validate_event runs before the fold.
6. loop/reducer.py: drop the "or has an illegal gap" overclaim from
ChainBreakError — a mid-stream deletion is a sequence error and a truncated
tail is caught only by an anchor.
7. repo-os-contract.md 16: rewrap the mid-word break so "hash-chain-consistent"
renders intact.
8. repo-os-contract.md 16: replace the full-rewrite bullet's parenthetical with
what the committed pin actually asserts (three projection-disagreement codes
absent, three event_store cleanliness flags true) plus the rule that a future
standalone event-store cross-check must be added to the pin.
9. repo-os-contract.md 22: document the absent-store residue shape
{"present": false, "sidecar_residue": true}; extend the fails-doctor code
enumeration with the chain codes and invalid_event; state that chain.head is
null for a BROKEN chain too and that event_chain_broken is the discriminator;
state that a tamper also violating event@1 surfaces as invalid_event because
validation runs before the fold.
Resolves the two-way collision between #80's sidecar fix and this branch's chain work, both of which rewrote the read-only EventStore connect path. - loop/runtime.py: this branch's architecture wins. #80's _readonly_query is superseded by _read_only_connect + _read_store, which apply the same immutable-URI gate and add a two-stage plain mode=ro retry so a lost race with a live appender cannot mint a false corrupt_store (D4). The shared helper also covers run/simulate/run-control, not just status/replay/doctor. #80's inline SELECT was the pre-0.10.0 10-column shape; row reading now goes through read_event_rows, which is chain-aware. - scripts/test_doctor_eventstore.py: union. #80's sidecar regression test is preserved verbatim alongside this branch's coverage. - scripts/test_loop_cli_status_replay.py: taken from main as-is. Its strengthening is load-bearing (the old test snapshotted the directory listing once, so a newly created sidecar was invisible to the comparison) and it passes against this implementation.
There was a problem hiding this comment.
Pull request overview
Implements the first slice of tamper-evident provenance by introducing a store-computed hash-linked event chain, surfacing chain health consistently across read verbs, and adding an anchored doctor gate plus an explicit migration path for legacy stores.
Changes:
- Add per-run hash chaining (
prev_event_hash/event_hash) with canonical hashing (loop/chain.py) and reducer enforcement (event_chain_broken). - Introduce
loop migrateto widen legacy stores to generation 2 without rewriting historical rows, and expose chain metadata in doctor/status/replay/simulate. - Add
--expect-chain-headanchoring to doctor/validate/verify and plumb it through the GitHub Action to record/enforce chain heads.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/test_migrate_cli.py | Adds subprocess-driven CLI contract tests for the new loop migrate command and typed error behavior. |
| scripts/test_loop_simulate_zero_writes.py | Extends “zero writes” proofs to cover read verbs and legacy (unchained) stores. |
| scripts/test_loop_cli.py | Updates help coverage to require migrate to appear in --help. |
| scripts/test_event_chain.py | Adds unit/integration coverage for canonicalization, chain verification, store chaining, and migration behavior. |
| scripts/test_doctor_eventstore.py | Extends doctor integration tests for chain nesting, enforcement, anchoring, and sidecar residue behavior. |
| scripts/test_adversarial_chain.py | Adds adversarial tests that pin both detections and known limitations (unanchored cases). |
| scripts/chain_fixtures.py | Introduces shared fixtures for byte-faithful legacy stores and adversary trigger manipulation. |
| schemas/event.schema.json | Extends event@1 schema with optional chain fields and documentation updates. |
| reference/repo-os-contract.md | Adds normative spec for hash chaining, anchor semantics, new doctor codes, and updated integrity boundary. |
| README.md | Documents hash chaining and the anchor requirement at a high level. |
| loop/runtime.py | Centralizes read-only SQLite access, adds chain/head fields to reports, and adds anchor/sidecar-residue/column-downgrade checks to doctor’s event-store gate. |
| loop/runner.py | Routes reads through shared read path and translates chain-break/store operational errors to typed runtime errors. |
| loop/runcontrol.py | Translates store operational failures into typed runtime errors for run-control appends. |
| loop/reducer.py | Enforces chain linkage during fold and exposes chain_head/unchained_prefix in projections. |
| loop/migrate.py | Adds explicit, idempotent migration that widens legacy stores to generation 2 without rewriting rows. |
| loop/events.py | Adds chain columns to fresh stores, store-computed chaining on append, shared row projection, and typed operational/decode errors. |
| loop/contract.py | Extends doctor_report to accept an optional expected chain head and pass it to runtime consistency checks. |
| loop/chain.py | Implements canonical JSON hashing, per-record link checks, and full-stream chain verification with optional anchoring. |
| loop/main.py | Adds migrate command and plumbs --expect-chain-head into doctor/validate/verify with CLI validation. |
| action.yml | Adds expect-chain-head input and outputs the observed chain head even on doctor failure. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def _read_store(path: Path, read: Callable[[sqlite3.Connection], _T]) -> _T: | ||
| """Run one read; a lost immutable=1 race retries plainly before counting as corruption.""" | ||
| try: | ||
| conn = sqlite3.connect(f"{path.absolute().as_uri()}?{_readonly_query(path)}", uri=True) | ||
| try: | ||
| rows = conn.execute( | ||
| "SELECT run_id, sequence, event_id, type, actor, causation_id, " | ||
| "correlation_id, ts, payload, artifact_hashes FROM events " | ||
| "WHERE run_id = ? ORDER BY sequence ASC", | ||
| (run_id,), | ||
| ).fetchall() | ||
| finally: | ||
| conn.close() | ||
| with closing(_read_only_connect(path)) as conn: | ||
| return read(conn) |
| from .events import (EventRowDecodeError, EventStoreOperationalError, SQLiteEventStore, | ||
| read_event_rows, validate_event) | ||
| from .paths import resolve_loop_paths | ||
| from .reducer import reduce_events | ||
| from .runtime import RuntimeStoreError | ||
| from .reducer import ChainBreakError, reduce_events | ||
| from .runtime import RuntimeStoreError, _read_store |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5aa55a2d9d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| for record in events: | ||
| issue = link_issue(record, head) |
There was a problem hiding this comment.
Reject non-contiguous streams before verifying links
verify_chain() never checks that the first event has sequence 0 or that later sequences are contiguous. Consequently, deleting rows from an unchained legacy prefix—or passing a self-consistent genesis whose sequence is greater than zero—still returns ok: true and underreports unchained_prefix, even though this API is documented as verifying a complete run stream. Validate sequence continuity while iterating so incomplete exports cannot be certified.
Useful? React with 👍 / 👎.
| return {"ok": True, "migrated": not already, "store": str(path), "user_version": 2, | ||
| "unchained_rows": unchained, "chained_from_sequence": 0 if top is None else top + 1} |
There was a problem hiding this comment.
Preserve the actual chain start in idempotent migration reports
When migrate is rerun after chained events exist—or is run on a fresh generation-2 store—this computes chained_from_sequence as the next unused sequence rather than the sequence where chaining actually began. For example, a fresh store with one chained event reports 1 although its chain starts at 0; consumers therefore receive an incorrect provenance boundary from an otherwise successful idempotent migration. Derive this field from the earliest non-null event_hash (or the legacy-prefix boundary when no chained event exists).
Useful? React with 👍 / 👎.
Summary
Slice 1/5 of the tamper-evident-provenance program. Adds a hash-linked event chain to the
EventStore and an anchored doctor gate over it.
event_hash/prev_event_hash, computed inside the store underBEGIN IMMEDIATEso the link cannot race.Callers never supply a digest.
event_chain_brokenis raised at all four folding surfaces, so abroken chain fails
status,replay,doctor, andrunalike — not just the one verb areader happened to call.
loop migrate. Brings a pre-0.10.0 store to generation 2. The never-migrated prefix isreported as
unchained_prefixand never elided.chainblock nested underevent_store;--expect-chain-head <sha256>pins the log to an externally remembered head; achain_columns_missingcross-check catches the lazy downgrade; a sidecar tripwire turns"database gone but
-wal/-shmleft behind" into a hardmissing_event_store.events.db-wal/events.db-shmsidecars #80 began — see Relation to Prevent EventStore read verbs from creatingevents.db-wal/events.db-shmsidecars #80 below.action.ymlrecords the chain head on every run and can enforce it as an anchor.reference/repo-os-contract.md§16/§22, with machine-pinned conformancevectors so a third-party implementation can be checked against the same canonicalization.
not catch are committed as passing tests asserting the weak outcome, so a future fix turns
them loud rather than letting the gap rot silently.
Version bumps are deliberately absent; the release cut is a separate PR.
Relation to #80
#80 landed the narrow sidecar fix while this branch was in flight, and the two collided in the
same function. Resolved by merge, in this branch's favour:
events.db-wal/events.db-shmsidecars #80's regression tests are preserved.test_doctor_event_store_reads_do_not_leave_wal_or_shm_sidecarsis kept verbatim, and Prevent EventStore read verbs from creating
events.db-wal/events.db-shmsidecars #80's strengthening oftest_events_db_is_opened_strictly_read_only_no_write_side_effectsis taken from main as-is —that one is load-bearing (the previous version snapshotted the directory listing once, so a
newly created sidecar was invisible to the
aftercomparison).events.db-wal/events.db-shmsidecars #80's_readonly_queryhelper is superseded by the shared_read_only_connect/_read_storeconnector and does not survive as dead code (zero occurrences post-merge).The claim this branch makes is therefore not "closes the 0.9.0 wal/shm known limitation" —
#80 closed the narrow case first. It completes it: same immutable-URI gate, plus a two-stage
plain
mode=roretry so a lost race with a live appender cannot mint a falsecorrupt_store(D4), applied uniformly across
status/replay/doctorandrun/simulate/run-controlvia the shared helper, plus a sidecar-residue tripwire (
missing_event_store) and zero-carve-outfull-tree equality proofs on both store generations.
#80's inline read was also the pre-0.10.0 10-column
SELECT; row reading now goes through thechain-aware
read_event_rows.Integrity boundary
Verbatim from
reference/repo-os-contract.md§16:Negative control
The detection tests were run against the pre-chain kernel to prove they fail without this
change.
git worktree add …/.tmp/chain-negctl main(worktree atc39a909, v0.9.0), overlaidexactly
loop/chain.py,scripts/chain_fixtures.py,scripts/test_adversarial_chain.py(
git statusin the worktree showed exactly those three as untracked, nothing modified).Run 1 — the test as committed. It imports and runs on
main(no collection or importerror), and fails — but at the SQL layer, because the splice-with-recompute attack is literally
unstageable against a kernel whose
eventstable has no chain columns:Run 2 — the adapted control, for a clean assertion failure. On the unchained kernel the
adversary has no hash to repair, so the splice degenerates to a bare payload rewrite; same
assertion, control-worktree-only file (never committed), importing the overlaid
_chained_workspace/_codes:The tamper is invisible on the old kernel (
doctorreportsok: True, zero issues) and hard-failson this one.
Test evidence
Measured at the merge head. Every row states its dependency set — this repo has an asymmetric
optional-dependency baseline, so a single number is meaningless without it.
c39a909, fresh)c39a909, fresh)Gates:
validate_frontmatter.py→ 9 skills, 0 errors.self_eval.py→ 13/13 (1.000).The live-vs-fresh
+2 / −2delta in every row is the documented checked-when-present class(
test_contract_records.py:180,test_docs_adoption.py:68), which pass in a live checkout andskip in a fresh worktree or CI.
Footnote — pre-merge fresh-worktree numbers at
d7ccdcewere 1019/16, 949/86, 1029/15. Theuniform +1 is #80's single added test; #80's other change strengthened an existing test rather
than adding one.
Behavior matrix
Each row names the test that proves it.
ok;chain == {"head": null, "unchained_prefix": N}test_legacy_store_doctor_ok_and_chain_nullchain_headisNonetest_legacy_store_doctor_ok_and_chain_null+ legacy zero-write variantevent_chain_broken(pinned limitation)test_legacy_store_tamper_is_undetectable_pinnedok; unchained prefix reportedtest_migrated_store_doctor_reports_unchained_prefixok; genesis-after-prefix headtest_migrated_store_after_append_reports_genesis_headok; chain head presenttest_status_and_replay_expose_chain_head,test_doctor_nests_chain_under_event_storeevent_chain_brokentest_tampered_store_fails_doctor_status_and_replay_with_event_chain_brokenRuntimeStoreError("event_chain_broken")test_run_on_tampered_store_reports_event_chain_brokenevent_chain_brokentest_splice_detected,test_reorder_detectedevent_chain_broken(pinned)test_full_rewrite_with_recompute_passes_without_anchor_pinned--expect-chain-headchain_anchor_mismatchchain_anchor_mismatchtest_truncation_alone_not_detected_but_anchor_catches_itchain_columns_missingtest_chain_columns_dropped_but_version_2_fails_doctorchain_anchor_mismatchtest_column_drop_downgrade_is_silent_without_anchor_pinnedtest_legacy_style_ten_column_insert_is_refused_by_a_fresh_storecorrupt_store, no tracebacktest_in_row_json_corruption_fails_doctor_without_tracebackRuntimeStoreError("invalid_event")test_invalid_event_now_fails_status_instead_of_being_discarded{"present": false}test_absent_store_without_flag_or_sidecars_stays_byte_stablemissing_event_storetest_sidecar_residue_without_db_fails_doctorchain_anchor_mismatchtest_expect_chain_head_with_missing_store_fails_doctor,…_with_unreadable_store_…test_read_verbs_leave_no_wal_sidecars_on_clean_store+ zero-write variantstest_cli_doctor_accepts_flag_before_targettest_cli_rejects_flag_on_other_commands_and_creates_nothingtest_cli_refuses_a_schema_drifted_store_without_a_traceback(scripts/test_migrate_cli.pyonly, after a review-mandated dedup)Notes for reviewers
commits, but that split is internally red — the DDL alone fails 123 tests. Folded per the
plan's end-green invariant; zero design change.
migrateperforms no schema-shape validation. Doctor is the shape gate; migrate isdeliberately not a second one.
ci.ymlpasses no anchor and theexamples/contractscarry no store, so the
--expect-chain-headpath is covered by tests but not exercisedend-to-end in CI.
variant is stronger than the plan required; that was kept rather than levelled down.
DuplicateEventErrorswallows allIntegrityError, not just the uniqueness violation._connect()statements sit outside theOperationalErrorwrap, so a connect-time lock stillsurfaces a raw traceback.
reduce_events(initial=<pre-chain snapshot>)has a false-mismatch hazard. No in-packagecaller does this today.