Skip to content

feat(kernel): hash-linked event chain with anchored doctor gate (v0.10.0, slice 1/5) - #82

Merged
SollanSystems merged 17 commits into
mainfrom
feat/event-chain
Jul 25, 2026
Merged

SollanSystems merged 17 commits into
mainfrom
feat/event-chain

Conversation

@SollanSystems

Copy link
Copy Markdown
Owner

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.

  • Hash-linked chain, store-computed. Every appended event carries event_hash /
    prev_event_hash, computed inside the store under BEGIN IMMEDIATE so the link cannot race.
    Callers never supply a digest.
  • Reducer enforcement. event_chain_broken is raised at all four folding surfaces, so a
    broken chain fails status, replay, doctor, and run alike — not just the one verb a
    reader happened to call.
  • loop migrate. Brings a pre-0.10.0 store to generation 2. The never-migrated prefix is
    reported as unchained_prefix and never elided.
  • Doctor chain block + anchor. A chain block nested under event_store;
    --expect-chain-head <sha256> pins the log to an externally remembered head; a
    chain_columns_missing cross-check catches the lazy downgrade; a sidecar tripwire turns
    "database gone but -wal/-shm left behind" into a hard missing_event_store.
  • Sidecar-free reads. Completes the fix Prevent EventStore read verbs from creating events.db-wal/events.db-shm sidecars #80 began — see Relation to Prevent EventStore read verbs from creating events.db-wal/events.db-shm sidecars #80 below.
  • action.yml records the chain head on every run and can enforce it as an anchor.
  • Normative docs. reference/repo-os-contract.md §16/§22, with machine-pinned conformance
    vectors so a third-party implementation can be checked against the same canonicalization.
  • Adversarial suite with four pinned honest limitations — the attacks this design does
    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:

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=ro retry so a lost race with a live appender cannot mint a false corrupt_store
(D4), applied uniformly across status/replay/doctor and run/simulate/run-control
via the shared helper, plus a sidecar-residue tripwire (missing_event_store) and zero-carve-out
full-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 the
chain-aware read_event_rows.

Integrity boundary

Verbatim from reference/repo-os-contract.md §16:

It does not detect:

  • A full in-workspace recompute. A process with write access to the
    workspace can rewrite history, re-chain from genesis, and forge
    .loop/state.json and terminal_state.json to agree. With no anchor
    supplied, the event-store block of such a report is wholly clean —
    state_json_agrees, deterministic, and legal_sequence all true, a
    FailedBlocked run laundered into Succeeded — as pinned by
    scripts/test_adversarial_chain.py::test_full_rewrite_with_recompute_passes_without_anchor_pinned.
    (In the probe that produced that fixture the report was also globally ok
    with zero issues. What the committed pin actually asserts is narrower and
    event-store-scoped: that event_chain_broken is absent, that the three
    projection-disagreement codes state_field_mismatch,
    desynced_terminal_window and terminal_state_mismatch stay absent, and
    that the three event-store cleanliness flags state_json_agrees,
    deterministic and legal_sequence stay true. A future standalone
    event-store cross-check — a new issue code appended directly to issues, as
    chain_columns_missing is — would not move any of those, so it must be added
    to this pin's assertions when it is introduced.)
  • A chain-column downgrade. Dropping event_hash/prev_event_hash, or
    rebuilding the store without them, silently downgrades a chained history to
    an unchained one. An unchained or legacy doctor report is not proof of
    provenance. The chain_columns_missing check catches only the lazy variant
    — columns dropped while user_version still declares generation 2; a
    downgrade that also resets user_version leaves nothing but the anchor.
  • Deleting the store outright, when no SQLite sidecars remain and no
    --expect-chain-head is supplied: a bare loop doctor reads that as a
    valid never-ran contract (§22).
  • Well-formed lies. Nothing in the chain judges whether a payload is
    true. A truthfully-recorded, correctly-hashed event asserting a test
    passed when it did not is chain-clean by construction; that is the job of
    evidence@1, the held-out gate, and the verifier — not of the digest.
  • Anything in a never-migrated prefix. Rows written before migration have
    no hashes to break, so there is no retroactive coverage: doctor reports them
    as unchained_prefix and never elides them.

The mid-run window. An anchor certifies the log only up to the anchored
head. Everything appended after the last externally-read anchor — including a
rewrite of the suffix — is unverified until the next anchor is read and
remembered outside the workspace. The chain narrows the tampering window; it
does not close it.

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 at c39a909, v0.9.0), overlaid
exactly loop/chain.py, scripts/chain_fixtures.py, scripts/test_adversarial_chain.py
(git status in 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 import
error), and fails — but at the SQL layer, because the splice-with-recompute attack is literally
unstageable against a kernel whose events table has no chain columns:

    def test_splice_detected(tmp_path):
        ws = _chained_workspace(tmp_path)
        store_path = ws / ".loop" / "events.db"
        drop_triggers(store_path)
        conn = sqlite3.connect(str(store_path))
        try:
            conn.execute("UPDATE events SET payload = '{\"iteration_id\":1,\"outcome\":\"task_passed\"}' "
                         "WHERE sequence = 1")
            # recompute ONLY the spliced row's own hash: its successor still cites the original
>           row = conn.execute("SELECT run_id, sequence, event_id, type, actor, causation_id, "
                               "correlation_id, ts, payload, artifact_hashes, prev_event_hash "
                               "FROM events WHERE sequence = 1").fetchone()
E                              sqlite3.OperationalError: no such column: prev_event_hash

scripts/test_adversarial_chain.py:126: OperationalError
=========================== short test summary item ============================
FAILED scripts/test_adversarial_chain.py::test_splice_detected - sqlite3.Oper...
1 failed in 1.90s

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:

    def test_splice_detected_adapted(tmp_path):
        ws = _chained_workspace(tmp_path)
        store_path = ws / ".loop" / "events.db"
        drop_triggers(store_path)
        conn = sqlite3.connect(str(store_path))
        try:
            conn.execute("UPDATE events SET payload = '{\"iteration_id\":1,\"outcome\":\"task_passed\"}' "
                         "WHERE sequence = 1")
            conn.commit()
        finally:
            conn.close()
        restore_triggers(store_path)
>       assert "event_chain_broken" in _codes(doctor_report(ws))
E       AssertionError: assert 'event_chain_broken' in set()
E        +  where set() = _codes({'ok': True, 'paths': {...}, 'validation_mode': 'jsonschema', 'requested_mode': 'auto', ...})
E        +    where {'ok': True, ...} = doctor_report(PosixPath('/tmp/pytest-of-khall/pytest-76/test_splice_detected_adapted0/workspace'))

scripts/test_negctl_adapted.py:22: AssertionError
=========================== short test summary info ============================
FAILED scripts/test_negctl_adapted.py::test_splice_detected_adapted - Assertio...
1 failed in 1.84s

The tamper is invisible on the old kernel (doctor reports ok: True, zero issues) and hard-fails
on 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.

Environment Dependency set Result
Fresh worktree (merge head) pyyaml + jsonschema + pytest 1020 passed / 16 skipped
Fresh worktree (merge head) pyyaml only 950 passed / 86 skipped
Fresh worktree (merge head) pyyaml + jsonschema + hypothesis 1030 passed / 15 skipped
Live checkout pyyaml + jsonschema + pytest 1022 passed / 14 skipped
Live checkout pyyaml only 952 passed / 84 skipped
Live checkout pyyaml + jsonschema + hypothesis 1032 passed / 13 skipped
Base of branch (c39a909, fresh) pyyaml + jsonschema + pytest 933 passed / 16 skipped
Base of branch (c39a909, fresh) pyyaml only 864 passed / 85 skipped

Gates: validate_frontmatter.py → 9 skills, 0 errors. self_eval.py → 13/13 (1.000).

The live-vs-fresh +2 / −2 delta 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 and
skip in a fresh worktree or CI.

Footnote — pre-merge fresh-worktree numbers at d7ccdce were 1019/16, 949/86, 1029/15. The
uniform +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.

Store Verb(s) Expected Test
legacy (never migrated) doctor ok; chain == {"head": null, "unchained_prefix": N} test_legacy_store_doctor_ok_and_chain_null
legacy status/replay unchanged; chain_head is None test_legacy_store_doctor_ok_and_chain_null + legacy zero-write variant
legacy simulate zero writes, tree-hash equal legacy zero-write variant
legacy, tampered doctor no event_chain_broken (pinned limitation) test_legacy_store_tamper_is_undetectable_pinned
migrated, no new appends doctor ok; unchained prefix reported test_migrated_store_doctor_reports_unchained_prefix
migrated + appends doctor ok; genesis-after-prefix head test_migrated_store_after_append_reports_genesis_head
fresh v2 status/replay/doctor ok; chain head present test_status_and_replay_expose_chain_head, test_doctor_nests_chain_under_event_store
fresh v2, payload flipped doctor/status/replay hard fail event_chain_broken test_tampered_store_fails_doctor_status_and_replay_with_event_chain_broken
fresh v2, payload flipped run RuntimeStoreError("event_chain_broken") test_run_on_tampered_store_reports_event_chain_broken
fresh v2, splice / reorder doctor hard fail event_chain_broken test_splice_detected, test_reorder_detected
fresh v2, full recompute + forged state doctor (no flag) no event_chain_broken (pinned) test_full_rewrite_with_recompute_passes_without_anchor_pinned
fresh v2, full recompute doctor --expect-chain-head hard fail chain_anchor_mismatch same test, second half
fresh v2, truncated tail doctor / doctor+anchor pass / chain_anchor_mismatch test_truncation_alone_not_detected_but_anchor_catches_it
fresh v2, columns dropped + version 2 doctor hard fail chain_columns_missing test_chain_columns_dropped_but_version_2_fails_doctor
fresh v2, columns dropped + version reset doctor / +anchor pass (pinned) / chain_anchor_mismatch test_column_drop_downgrade_is_silent_without_anchor_pinned
pre-0.10.0 10-column INSERT fresh store refused by the DB test_legacy_style_ten_column_insert_is_refused_by_a_fresh_store
in-row JSON corruption doctor corrupt_store, no traceback test_in_row_json_corruption_fails_doctor_without_traceback
schema-invalid event row status RuntimeStoreError("invalid_event") test_invalid_event_now_fails_status_instead_of_being_discarded
absent store, no sidecars, no flag doctor byte-stable {"present": false} test_absent_store_without_flag_or_sidecars_stays_byte_stable
absent store + sidecars doctor hard fail missing_event_store test_sidecar_residue_without_db_fails_doctor
absent / unreadable store + flag doctor hard fail chain_anchor_mismatch test_expect_chain_head_with_missing_store_fails_doctor, …_with_unreadable_store_…
clean stores, both generations simulate/status/replay/doctor zero new files, tree-hash equal (no carve-out) test_read_verbs_leave_no_wal_sidecars_on_clean_store + zero-write variants
CLI, flag before target doctor resolves the real target; exit 0/1 test_cli_doctor_accepts_flag_before_target
CLI, flag on other verbs scaffold/status exit 2, nothing created, refusal on stderr test_cli_rejects_flag_on_other_commands_and_creates_nothing
schema-drifted store CLI refused, no traceback test_cli_refuses_a_schema_drifted_store_without_a_traceback (scripts/test_migrate_cli.py only, after a review-mandated dedup)

Notes for reviewers

  • Tasks 3 and 4 were folded. The plan split the DDL and the append-path change into separate
    commits, but that split is internally red — the DDL alone fails 123 tests. Folded per the
    plan's end-green invariant; zero design change.
  • migrate performs no schema-shape validation. Doctor is the shape gate; migrate is
    deliberately not a second one.
  • No live CI anchor coverage yet. ci.yml passes no anchor and the examples/ contracts
    carry no store, so the --expect-chain-head path is covered by tests but not exercised
    end-to-end in CI.
  • The two simulate zero-write tests assert at different strengths on purpose. The legacy
    variant is stronger than the plan required; that was kept rather than levelled down.
  • Known parks (pre-existing, not introduced here; filed post-merge):
    • DuplicateEventError swallows all IntegrityError, not just the uniqueness violation.
    • _connect() statements sit outside the OperationalError wrap, so a connect-time lock still
      surfaces a raw traceback.
    • reduce_events(initial=<pre-chain snapshot>) has a false-mismatch hazard. No in-package
      caller does this today.

…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.
…, 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.
Copilot AI review requested due to automatic review settings July 25, 2026 05:57
@SollanSystems
SollanSystems enabled auto-merge (squash) July 25, 2026 05:57
@SollanSystems
SollanSystems merged commit 7002580 into main Jul 25, 2026
11 checks passed
@SollanSystems
SollanSystems deleted the feat/event-chain branch July 25, 2026 05:58

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 migrate to widen legacy stores to generation 2 without rewriting historical rows, and expose chain metadata in doctor/status/replay/simulate.
  • Add --expect-chain-head anchoring 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.

Comment thread loop/runtime.py
Comment on lines +56 to +60
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)
Comment thread loop/runner.py
Comment on lines +14 to +18
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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread loop/chain.py
Comment on lines +72 to +73
for record in events:
issue = link_issue(record, head)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread loop/migrate.py
Comment on lines +44 to +45
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}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants