Audit — ADR-007 η session lifecycle
A whole-system code review of the η Proof Store + ζ Decision Trail surfaces surfaced three architectural gaps. They share one substrate (the audit_session_start → thought_record × N → merkle_finalize flow) and the fix for one constrains the fix for the others, so they belong in a single ADR rather than three.
This audit inventories each gap with concrete code citations against feature/adr-007-eta-session-lifecycle@HEAD (worktree base origin/main at 86e430fb).
Gap C1 — η is broken end-to-end via MCP
Symptom: the documented proof-grade workflow audit_session_start → thought_record × N → merkle_finalize always fails at merkle_finalize with ERR_NO_RECORDS.
Root cause: the thought_record MCP tool does not accept a session_id parameter, so every record inserts with session_id = NULL, but merkle_finalize selects records by WHERE session_id = ?. The SELECT returns 0 rows for any non-NULL session_id, the handler throws NoThoughtRecordsError, the caller sees ERR_NO_RECORDS.
Code citations (all paths repository-relative):
src/domains/trail/repository.ts:114-119—CreateThoughtRecordInputSchemadefines four input fields:type,task_id,agent_id,content. There is nosession_idfield. This Zod schema is also exported asThoughtRecordToolInputSchema(line 122) and is the schema bound to the MCP tool at line 375.src/domains/trail/repository.ts:226-230— theINSERT INTO thought_recordsstatement lists nine columns and binds nine values. None of them issession_id. Migration 006 added the column nullable, so the INSERT silently succeeds withsession_id = NULL.src/db/migrations/006_eta.sql:54—ALTER TABLE thought_records ADD COLUMN session_id TEXT;(nullable, no default). Migration was shipped in P0.8.3 to supportmerkle_finalize’s session-scoped collection query.src/tools/merkle.ts:296-300— thehashesStmtinfinalizeMerkleRootrunsSELECT hash FROM thought_records WHERE session_id = ? ORDER BY rowid ASC. Since every row hassession_id = NULL, this returns the empty set for any session id passed in.src/tools/merkle.ts:321-324— empty rows triggersthrow new NoThoughtRecordsError(sid), which the MCP handler maps to{ ok: false, error: { code: 'ERR_NO_RECORDS', … } }(line 480-489).
Impact: the entire η axis is unreachable from MCP. The 6-tool proof-grade chain documented in CLAUDE.md §7 (audit_session_start → thought_record → audit_verify_chain → thought_record → merkle_finalize → merkle_root) cannot complete. audit_verify_chain still works because it filters by task_id, not session_id, but the chain cannot be anchored in a Merkle root via the MCP surface.
Why this was not caught by tests: every test that calls finalizeMerkleRoot with rows present uses the repository function createThoughtRecord directly with bypass mechanisms (or pre-seeded SQL inserts that include session_id). No test exercises the MCP tool path end-to-end with a non-NULL session linkage. This is the C1 in “C1 (η broken end-to-end via MCP)” — the MCP path is what’s broken.
Gap #6 — post-finalize insert is allowed
Symptom: after merkle_finalize writes a root for session X, the schema and code happily accept new thought_records inserts that target session X. The Merkle root would no longer represent the chain it purports to anchor.
Root cause: the immutability story in η stops at merkle_roots.session_id being PRIMARY KEY (so the root row itself cannot be overwritten), but there is no constraint on thought_records that says “no inserts for a finalized session.” Even if C1 is fixed and session_id propagates correctly, a malicious or careless caller can audit_session_start → thought_record → merkle_finalize → thought_record and the second thought_record is silently appended under a session whose root is already published.
Code citations:
src/db/migrations/006_eta.sql:38-43—audit_sessionshas onlysession_id,intent,task_id,started_at. There is nofinalized_atcolumn or boolean flag.src/db/migrations/006_eta.sql:47-52—merkle_rootshas PRIMARY KEY onsession_id. This makes the root immutable but says nothing about subsequentthought_recordsinserts.src/db/migrations/006_eta.sql:54-60—thought_records.session_idis added with no CHECK and no trigger. The indexidx_trail_sessionis for read performance only.src/domains/trail/repository.ts:205-267—createThoughtRecordperforms no read againstmerkle_rootsbefore inserting. The transaction reads only the latest record for chain linkage, then INSERTs.
Impact (post-C1): a finalized Merkle root no longer guarantees the integrity of the record set it was built from. The “proof of work completion” guarantee implicit in §7 of CLAUDE.md is breakable by appending a record after finalize.
Why this was not caught: the existing AlreadyFinalizedError catches the second finalize, not a post-finalize insert. The two cases were conflated in P0.8.3.
Gap #7 — ζ↔η decoupling
Symptom: the two halves of the proof chain (ζ records, η root) are joined only by a SQL JOIN inside merkle_finalize’s read query. Once that transaction commits, there is no way to walk from a record back to the root that anchors it, or from a root forward to the records anchored under it, without re-running the same query against thought_records.
Root cause: the schema couples by lookup, not by reference. thought_records.session_id is the one-way link from record to session. There is no record→root pointer. There is no MCP tool exposing “given a Merkle root, list the anchored records” — the only η reads are merkle_finalize (write) and merkle_root (root-only read).
Code citations:
src/domains/trail/verifier.ts:81-83—audit_verify_chainfilters bytask_idonly. There is nosession_idfilter and norootfilter. The verifier walks the ζ chain by content-hash linkage; it does not consultmerkle_rootsand never computes “is this record anchored?”.src/tools/merkle.ts:495-519—merkle_rootreturns the row frommerkle_rootsfor one session. The output ({session_id, root, record_count, finalized_at}) carries no list of anchored record ids and no way to enumerate them.src/tools/merkle.ts:447-493—merkle_finalizereads all the hashes inside the transaction, builds the tree, persists(session_id, root, record_count, finalized_at), and discards the leaf hashes. The leaves are still derivable viaSELECT hash FROM thought_records WHERE session_id = ?, but there is no tool exposing that path and no defensive constraint that says “the live SELECT must agree with the originalrecord_count.”
Impact: Sigma cannot answer the question “which thought records does this Merkle root cover?” via the MCP surface. The answer is reachable by hand-crafting SQL or by reading the finalize transaction logs, but it is not a first-class η operation. This makes audit replay and external proof verification harder than it needs to be.
Constraint on the fix: ADR-004 §”Phase 0 shipped surface” locks the tool count at 14. Adding a new MCP tool (merkle_anchored_records, merkle_replay, etc.) would breach that lock and require an ADR-004 amendment. The cohesive alternative is a schema-level back-pointer that exposes the link to existing read tools without adding a new one.
Gap interaction — why one ADR
The three gaps are not independent. The fix for C1 — wiring session_id into thought_record input and INSERT — determines:
- What the post-finalize guard reads (#6). The trigger must reject inserts whose
session_idmatchesmerkle_roots.session_id. If C1 is fixed by adding the parameter to the tool but leaving the column nullable for back-compat, the guard must allowsession_id = NULLinserts (for callers that don’t anchor). - What the back-pointer column points at (#7).
thought_records.anchored_in_rootonly makes sense ifsession_idis present and reachesmerkle_finalize. If C1 is solved by a different mechanism (e.g. a separatesession_recordsjoin table), the back-pointer becomes redundant.
Therefore the ADR must present a coherent three-part decision, not three independent decisions. The recommended cohesive choice from the dispatch packet — optional session_id on thought_record, SQLite trigger for the guard, schema-level back-pointer for the coupling — is internally consistent and is evaluated as the primary option in the contract and ADR.
Out of scope for this audit
- Phase 1+ proof-grade extensions (k-of-n threshold roots, BFT signing, off-chain anchoring) — these layer on top of η, they do not replace it.
- The donor-era
unified_*audit/memory bridge — already struck per CLAUDE.md §4 R82.C reconciliation. - Migration of existing NULL-session records — the Phase 0 task store is empty enough at the time of writing that backfill is trivial; the migration plan is in scope for the ADR but the actual data move is a follow-up task.
- Refactor of
audit_verify_chainto acceptsession_iddirectly. This is a natural Phase 0.5 follow-up but is not required by the three gaps; the back-pointer column makes it derivable.
References
docs/audits/merkle-tools-audit.md(P0.8.3 audit; lays out the originalmerkle_finalizedesign)docs/audits/p0-7-2-thought-crud-audit.md(P0.7.2 audit; ζ table + repository)docs/agents/writeback-protocol.md:16— leaf-helper writeback rule (cited because Case B writeback flows through ζ)CLAUDE.md §7— the proof-grade ordering rule that breaks under C1