ADR-007: η Session Lifecycle — Wiring, Post-Finalize Guard, ζ↔η Coupling
Status: Accepted
Date: 2026-05-06
Accepted: 2026-05-06 (R84)
Round: R84 (drafted between rounds after R83 merge at 7bb68ac4; accepted in R84 ADR sweep)
Supersedes: None
Superseded by: None
Context
CLAUDE.md §7 documents Colibri’s proof-grade workflow as a six-call chain: audit_session_start opens a session, one or more thought_record calls append hash-chained reflections, audit_verify_chain walks the chain to confirm content-hash integrity and link consistency, a final thought_record lands the reflection that anchors the round, merkle_finalize builds a Merkle tree over the session’s thought-record hashes and persists the root, and merkle_root returns that root for downstream verification. This is the mechanism by which Colibri’s legitimacy axis (colibri-system.md §10) makes work auditable: not “did the executor finish” but “is the executor’s claim of completion anchored in an immutable artifact.”
A whole-system code review against the post-R83 tree (feature/adr-007-eta-session-lifecycle@HEAD, base origin/main at 86e430fb) surfaced three gaps in this chain. Each is, on its own, a small bug or omission; together they reveal that the η Proof Store axis was specified one layer above the wiring it actually needs. This ADR proposes a coherent three-part fix.
Gap C1 — η is broken end-to-end via MCP. The thought_record MCP tool does not accept a session_id parameter. ThoughtRecordToolInputSchema (src/domains/trail/repository.ts:114-119) lists four input fields — type, task_id, agent_id, content — and binds that schema to the registered MCP tool at src/domains/trail/repository.ts:375. The INSERT INTO thought_records statement at src/domains/trail/repository.ts:226-230 lists nine columns and binds nine values; session_id is not among them. Migration 006 added the column nullable (src/db/migrations/006_eta.sql:54), so the INSERT silently succeeds with session_id = NULL. But merkle_finalize collects records via SELECT hash FROM thought_records WHERE session_id = ? ORDER BY rowid ASC (src/tools/merkle.ts:296-300). With every row’s session_id being NULL, this SELECT returns the empty set, the handler throws NoThoughtRecordsError (src/tools/merkle.ts:321-324), and the caller sees ERR_NO_RECORDS. Every documented MCP-only invocation of η fails at finalize. Existing tests do not catch this because each one either calls createThoughtRecord directly (bypassing the MCP schema) or pre-seeds rows with explicit session_id values.
Gap #6 — post-finalize insert is allowed. Once merkle_finalize writes a row to merkle_roots for session X, nothing in the schema or the application code prevents a subsequent thought_record insert that targets session X. The PRIMARY KEY on merkle_roots.session_id (src/db/migrations/006_eta.sql:47-52) makes the root row immutable — a second finalize on the same session triggers AlreadyFinalizedError — but it says nothing about records inserted under that session afterwards. There is no CHECK constraint, no trigger, and no application-level guard inside createThoughtRecord (src/domains/trail/repository.ts:205-267). A caller can audit_session_start → thought_record → merkle_finalize → thought_record, and the second thought_record is silently appended under a session whose Merkle root is already published. The proof guarantee — “this root anchors exactly these records” — is breakable.
Gap #7 — ζ↔η are decoupled. The two halves of the proof chain join only inside the merkle_finalize transaction. Once that transaction commits, walking from a record to the root that anchors it (or from a root to the records it anchors) requires re-running the same SELECT against thought_records. There is no record→root pointer, no MCP tool exposing “given a Merkle root, list the anchored records,” and no defensive constraint that says the live SELECT must agree with the original record_count. audit_verify_chain (src/domains/trail/verifier.ts:81-83) filters by task_id, not session_id and not root — it never consults merkle_roots. merkle_root (src/tools/merkle.ts:495-519) returns {session_id, root, record_count, finalized_at} and stops. ADR-004’s 14-tool lock (docs/architecture/decisions/ADR-004-tool-surface.md §”Phase 0 shipped surface”) rules out adding a new MCP tool in Phase 0; the cohesive alternative is a schema-level back-pointer that exposes the link to the existing read tools.
The three gaps are not independent. Fixing C1 — wiring session_id into the input schema and the INSERT — determines what the #6 guard reads (which session_id values are eligible for the trigger) and what the #7 back-pointer references (the column gets populated only when session_id was provided at insert time). Treating them as one decision, with one ADR, is what the audit calls for.
Decision
Phase 0 closes the three η/ζ session-lifecycle gaps with a coherent, schema-first triple: opt-in session_id wiring at the MCP boundary, a SQLite trigger as the post-finalize guard, and a per-record back-pointer column populated inside the existing merkle_finalize transaction. Each piece is the minimum change that closes its gap without breaking existing callers and without breaching the ADR-004 14-tool lock. Together they make the documented proof-grade chain actually work.
Decision for C1 — wire session_id through thought_record
Add an optional session_id: string field to ThoughtRecordToolInputSchema, propagate it through createThoughtRecord, and bind it in the INSERT.
ThoughtRecordToolInputSchema (src/domains/trail/repository.ts:114-119) gains a fifth field: session_id: z.string().min(1).optional(). The CreateThoughtRecordInput interface gains the same readonly field. createThoughtRecord (src/domains/trail/repository.ts:205-267) extends its INSERT statement and parameter list to include the column. When the caller omits session_id, the column receives NULL — preserving every existing test fixture and every existing caller. When the caller supplies it, the column receives the supplied string and merkle_finalize can find the records.
This is the minimum-viable wiring: opt-in, non-breaking, and Phase-0 safe. Callers that do not care about Merkle anchoring (fast unit tests, ad-hoc memory writes, the β task pipeline’s writeback path that names a task_id but not a session_id) continue to work unchanged. Callers that do care — every Sigma seal, every executor’s writeback under audit_session_start, every proof-grade workflow in CLAUDE.md §7 — supply the parameter and the chain anchors as designed.
Decision for #6 — schema-level post-finalize guard
Add a SQLite trigger enforce_post_finalize_guard on thought_records BEFORE INSERT and BEFORE UPDATE OF session_id that raises SQLITE_ABORT with reason ERR_SESSION_FINALIZED when NEW.session_id is non-NULL and matches a row in merkle_roots.
The trigger ships as migration 007_post_finalize_guard.sql. Its body uses RAISE(ABORT, 'ERR_SESSION_FINALIZED') inside a WHEN clause that selects from merkle_roots. The application layer catches the abort in createThoughtRecord (or trusts SQLite to surface it as a transaction-aborting error) and maps it to a typed error class SessionFinalizedError analogous to AlreadyFinalizedError. The MCP handler maps that to { ok: false, error: { code: 'ERR_SESSION_FINALIZED', … } }.
The trigger explicitly allows NEW.session_id IS NULL to pass — preserving the C1 opt-in semantics — and explicitly allows UPDATE statements that do not touch session_id (no UPDATE OF match). The narrow scope keeps existing reflection-update flows working if any future task needs them.
Schema-level enforcement is preferred over an application guard inside createThoughtRecord for two reasons. First, an application guard is bypassable by direct SQL or by a future repository function added without reading this ADR; a trigger is unconditional. Second, the cost is one DDL statement that runs once at migration time; the runtime overhead is one indexed lookup per insert.
Decision for #7 — schema-level record↔root back-pointer
Add column thought_records.anchored_in_root TEXT (nullable, no FK), populated inside the existing merkle_finalize transaction with one UPDATE thought_records SET anchored_in_root = ? WHERE session_id = ?. Add index idx_trail_anchor on the column. Surface the field in the ThoughtRecord type and rowToRecord mapper so it is readable through thought_record_list.
The back-pointer is derivable — the live SELECT inside merkle_finalize already names the same set of records — but materializing it as a column makes the link reachable from any read of thought_records without an extra JOIN, without a new MCP tool, and without breaching the ADR-004 14-tool lock. Sigma can ask “which records does this root cover?” with a one-line WHERE clause and a Phase-0 read tool; an executor can ask “is this record anchored?” by reading one field on a thought_record_list response.
The column is nullable because not every record is anchored: pre-C1 NULL-session records stay NULL forever (and stay readable; the ADR introduces no migration that retroactively links them), and post-C1 records remain NULL until their session is finalized. The migration is 008_anchor_back_pointer.sql — a column add and an index DDL.
The Phase 1+ headroom is explicit: if k-of-n threshold roots arrive (multiple roots covering overlapping record sets), the back-pointer becomes a bottleneck and a join table (merkle_anchors(root, record_id)) is the natural successor. If consumers find the SELECT path clumsy, a new MCP tool merkle_anchored_records can be added under an ADR-004 14→15 amendment. Neither is needed in Phase 0; both are reachable from the chosen design without breaking back-compat.
Consequences
Positive
The proof-grade chain works end-to-end: audit_session_start → thought_record(session_id) × N → merkle_finalize → merkle_root produces a non-empty record set, builds a tree, persists a root, and returns it. CLAUDE.md §7 stops being aspirational. The post-finalize anchoring guarantee is restored: once merkle_roots has a row for session X, no subsequent thought_records insert can target session X without hitting ERR_SESSION_FINALIZED at the SQLite layer. The record→root link is now reachable as a SELECT on a single column, so Sigma’s audit-replay flow (“find every record anchored under this root”) needs no new MCP tool. The ADR-004 14-tool lock is preserved.
Negative
The schema migration adds two new artefacts (a trigger and a column with index); a downgrade path is not seamless, although both are individually reversible (DROP TRIGGER, ALTER TABLE … DROP COLUMN). The merkle_finalize transaction extends by one additional UPDATE statement that touches every record under the session; for sessions of typical Phase 0 size (≤100 records) this is negligible, but a benchmark in the Phase 1+ scaling task is warranted. Existing ζ tests that pre-seeded thought_records rows directly without a session_id continue to pass, so they no longer exercise the η happy path through the MCP boundary; the executor implementing this ADR must add new tests that cover the wired-through case explicitly. Finally, the trigger introduces a new error class (ERR_SESSION_FINALIZED); writeback flows that currently treat any insert error as a hard fail must learn to distinguish a finalize-bypass attempt from other DB errors.
Neutral
The η axis still exposes three MCP tools (audit_session_start, merkle_finalize, merkle_root); the ζ axis still exposes three (thought_record, thought_record_list, audit_verify_chain). The 14-tool surface and the runtime mode capability table are unchanged. The hash-input subset ({id, type, task_id, content, timestamp, prev_hash} per src/domains/trail/schema.ts) is unchanged — session_id does not enter the hash, just as agent_id does not (P0.7.1 §3). Adding either to the hash would invalidate every existing record. The graduation state of the η concept doc remains colibri_code: partial (per ADR-006); this ADR does not graduate it to complete because Phase 1+ proof extensions remain.
Implementation
The follow-up work decomposes into three executor slices that can ship in any order, although the natural sequence is C1 → #6 → #7. Each slice is sized for one 5-step chain.
Slice C1.1 — wire session_id through thought_record. Edit src/domains/trail/repository.ts to add session_id to the CreateThoughtRecordInput interface, the CreateThoughtRecordInputSchema, the INSERT statement, and the parameter list. No migration is needed (column exists since 006_eta.sql:54). Add tests: src/__tests__/domains/trail/thought_record.test.ts “MCP tool inserts session_id when provided”; src/__tests__/tools/merkle.test.ts “end-to-end MCP path: start → record × N → finalize → root succeeds.” Files touched: one source file, two test files. Migration count: zero. Tool surface delta: zero (input schema gains an optional field; that is not a tool count change).
Slice #6.1 — post-finalize guard trigger. Author migration src/db/migrations/007_post_finalize_guard.sql. The trigger is one CREATE TRIGGER statement guarded by BEFORE INSERT and BEFORE UPDATE OF session_id ON thought_records with body WHEN NEW.session_id IS NOT NULL AND EXISTS (SELECT 1 FROM merkle_roots WHERE session_id = NEW.session_id) BEGIN SELECT RAISE(ABORT, 'ERR_SESSION_FINALIZED'); END;. Add typed error class SessionFinalizedError in src/tools/merkle.ts (or a shared error module). Map the SQLite error in the MCP handler envelope. Tests: src/__tests__/tools/merkle.test.ts “post-finalize INSERT throws ERR_SESSION_FINALIZED”; “NULL session_id INSERT after finalize is permitted (opt-in semantics)”; “UPDATE without session_id change after finalize is permitted.” Files touched: one new migration, one source file (handler error mapping), one test file. Migration count: one (007_post_finalize_guard.sql).
Slice #7.1 — anchor back-pointer. Author migration src/db/migrations/008_anchor_back_pointer.sql adding ALTER TABLE thought_records ADD COLUMN anchored_in_root TEXT; and CREATE INDEX idx_trail_anchor ON thought_records(anchored_in_root);. Edit src/tools/merkle.ts:306-340 to add one UPDATE thought_records SET anchored_in_root = ? WHERE session_id = ? inside the existing transaction, after insertRoot.run and before the return. Edit src/domains/trail/schema.ts to add anchored_in_root: string | null to the ThoughtRecord type. Edit src/domains/trail/repository.ts:160-171 to extend rowToRecord with the new field, and src/domains/trail/repository.ts:284-345 (the four SELECT shapes) to project it. Tests: src/__tests__/tools/merkle.test.ts “anchored_in_root populated after merkle_finalize on every record in session”; src/__tests__/domains/trail/thought_record.test.ts “anchored_in_root null until finalize.” Files touched: one new migration, three source files, two test files. Migration count: one (008_anchor_back_pointer.sql).
Total Phase-0 cost across the three slices: two migrations, four source files, three or four test files. No new MCP tools. No schema change to merkle_roots. No change to the hash-input subset. Migration numbers 007 and 008 are free at branch base 86e430fb (verified by listing src/db/migrations/). The executor implementing this ADR should re-verify migration numbering at dispatch time in case a hygiene round has landed in between.
Alternatives Considered
C1 — alternatives
Alternative A — Optional session_id (chosen). Non-breaking; opt-in η anchoring; existing callers continue to work; new callers anchor by supplying the field. The minimum change that closes the gap.
Alternative B — Required session_id. Force every thought_record call to name a session. Rejected: this is breaking for every existing caller (all current tests, the β writeback path, ad-hoc memory inserts). The ζ axis is independently useful for callers that only want a hash chain and never anchor; demanding a session would couple two concerns that the schema deliberately keeps separate.
Alternative C — Server-side session inference. Auto-resolve “the most recent open session for this task_id” inside the MCP handler. Rejected: introduces hidden global state, breaks for tasks with overlapping concurrent sessions, and complicates testing. The opt-in form is explicit at the call site, which is the right ergonomic for an audit-grade tool where surprise is a defect.
#6 — alternatives
Alternative A — SQLite trigger (chosen). Schema-level enforcement; cannot be bypassed by direct SQL or by future repository functions added without reading this ADR. Cost: one DDL statement at migration time; one indexed lookup per insert.
Alternative B — Application-level guard in createThoughtRecord. Same SELECT, same WHERE, but inside the existing transaction in TypeScript. Rejected: bypassable by any other writer (a future tool, a maintenance script, the AMS donor task store). The trigger is the same logical check at a layer that actually owns the invariant.
Alternative C — finalized_at column on audit_sessions. Promote “finalized” from an implicit relationship (merkle_roots has a row) to an explicit timestamp on audit_sessions, with a CHECK constraint or trigger reading that column. Rejected: duplicates state already encoded in merkle_roots.session_id. Two sources of truth invite drift; one trigger reading the canonical source avoids the duplication.
#7 — alternatives
Alternative A — Schema back-pointer column (chosen). thought_records.anchored_in_root populated inside the merkle_finalize transaction. Bidirectional lookup as a SELECT. Preserves the ADR-004 14-tool lock. Phase-0 sized.
Alternative B — New MCP tool merkle_anchored_records. Direct, expressive, and the audit’s first-impulse design. Rejected for Phase 0 because adding a 15th tool requires an ADR-004 amendment; the back-pointer approach gets the same query power without spending the tool budget. If consumers find the SELECT path clumsy in Phase 1+, this tool is the natural amendment.
Alternative C — Join table merkle_anchors(root, record_id). Many-to-many capable; matches a Phase 1+ k-of-n threshold-root world. Rejected as over-engineering for Phase 0: merkle_roots.record_count already guarantees cardinality, and a join table doubles the row count without unlocking anything Phase 0 needs. The back-pointer is the strict subset of the join table that Phase 0 demands.
Verification
This ADR is verified as drafted-correctly when:
- The file exists at
docs/architecture/decisions/ADR-007-eta-session-lifecycle.mdwith frontmatterstatus: accepted. - The §Decision section names exactly three chosen options — one for each gap — and each names a load-bearing artifact (a Zod schema, a migration filename, a column name).
- The §Alternatives Considered section has three subsections, three options each, with a one-line tradeoff per option.
- The §Implementation section names migrations
007_post_finalize_guard.sqland008_anchor_back_pointer.sql(or whatever numbers the executor reserves at dispatch time). - The index file
docs/architecture/decisions/index.mdlists ADR-007 in its bullet index. npm run build && npm run lint && npm testis green on the worktree (no source changes, so the test count stays at the post-R83 baseline).
This ADR’s intent is verified — when a follow-up round implements it — by Sigma running:
grep -n "session_id" src/domains/trail/repository.tsreturning at least one match in the Zod schema and one in the INSERT statement (C1 wired).cat src/db/migrations/007_post_finalize_guard.sqlshowing aCREATE TRIGGERreferencingmerkle_rootsandRAISE(ABORT, 'ERR_SESSION_FINALIZED')(#6 in place).grep -n "anchored_in_root" src/db/migrations/008_anchor_back_pointer.sql src/tools/merkle.ts src/domains/trail/repository.tsreturning matches in all three (the column added in 008, populated inmerkle_finalize, projected inrowToRecord).- The end-to-end MCP test
merkle_finalize MCP path: start → record × N → finalize → root succeedspassing — proving that the chain documented in CLAUDE.md §7 works against the live tool surface, not just the repository functions.
If any of those four checks fails, the implementation has diverged from the ADR’s intent and Sigma reopens the round.
References
- ADR-004 — tool surface lock; ADR-007 §Decision-#7 chooses a schema solution to avoid breaching the 14-tool count.
- ADR-005 — Phase 0 / Phase 1.5 boundary template; ADR-007 mirrors the §Decision shape (one chosen option per concern, with an explicit Phase-1+ headroom note).
- ADR-006 —
colibri_codesemantics; this ADR does not graduate η, since post-implementation η stayspartial(Phase 1+ proof extensions remain). docs/agents/writeback-protocol.md— the writeback ordering rule (thought_recordbeforemerkle_finalize) which is unenforceable under C1 and becomes enforceable once C1 ships.docs/audits/adr-007-eta-session-lifecycle-audit.md— the audit this ADR responds to, with full code citations.docs/audits/merkle-tools-audit.md— original P0.8.3 audit; useful for understanding the design η started from.src/db/migrations/006_eta.sql— the migration that introducedaudit_sessions,merkle_roots, andthought_records.session_id; ADR-007’s migrations 007 and 008 are additive on top of it.- CLAUDE.md §7 — the writeback protocol that ADR-007 makes operational.
Draft for human / PM review. ADR-007 proposes a coherent three-part fix to the η session lifecycle. If accepted, three executor slices (C1.1, #6.1, #7.1) ship two migrations, four source files, and a refreshed test set; the η axis goes from “broken end-to-end via MCP” to “operational, guarded, and reverse-indexable” without breaching the ADR-004 14-tool lock or graduating the η concept beyond colibri_code: partial.