R89.A audit — merkle_finalize ERR_NO_RECORDS root cause

1. Summary of the gap

merkle_finalize always throws ERR_NO_RECORDS for any session created via the canonical audit_session_start + thought_record + merkle_finalize chain documented in CLAUDE.md §7 (proof-grade writeback) and exercised by R87 + R88.

The cause is a schema-vs-API disconnect:

  • thought_records.session_id exists at the DB layer (added by migration 006_eta.sql).
  • merkle_finalize’s collection query strict-equals on that column.
  • But createThoughtRecord (the only path the thought_record MCP tool calls) silently drops session_id — it is not in the Zod input schema, not in the function’s CreateThoughtRecordInput type, not in the INSERT column list, and not on the return shape.

Every row inserted by the live thought_record MCP tool therefore has session_id = NULL. merkle_finalize’s WHERE session_id = ? returns zero rows and the handler throws NoThoughtRecordsError, mapped to ERR_NO_RECORDS at the MCP envelope.

This is the failure mode R88.B documented (and parked under task 6f309f3a-7d22-4e2c-a02d-3a62fc46c834); R89.A is the fix.

2. File:line inventory at base fab4bf57

All paths absolute from repo root E:\AMS\.worktrees\claude\r89-a-merkle-session-binding\.

2.1 Schema layer — the column exists

File Line(s) Evidence
src/db/migrations/006_eta.sql 54 ALTER TABLE thought_records ADD COLUMN session_id TEXT; — nullable.
src/db/migrations/006_eta.sql 60 CREATE INDEX idx_trail_session ON thought_records(session_id);
src/db/schema.sql 72-77 Documents the column as “groups records into audit sessions for merkle_finalize’s collection query (ORDER BY rowid ASC, not created_at). NOT part of the thought-record hash subset”.

2.2 ζ Decision Trail repository — session_id is dropped

File Line(s) Evidence
src/domains/trail/repository.ts 81-86 CreateThoughtRecordInput interface has 4 fields: type, task_id, agent_id, content. No session_id.
src/domains/trail/repository.ts 114-119 CreateThoughtRecordInputSchema Zod object mirrors the interface — no session_id.
src/domains/trail/repository.ts 226-230 INSERT statement column list: (id, type, task_id, agent_id, content, timestamp, prev_hash, hash, created_at) — 9 columns, no session_id.
src/domains/trail/repository.ts 243-253 Values binding — 9 positional params, no session_id.
src/domains/trail/repository.ts 254-263 Return record shape — 8 fields, no session_id.
src/domains/trail/repository.ts 377 MCP tool handler: (input): ThoughtRecord => createThoughtRecord(getDb(), input)input Zod-parsed against the deficient schema.

2.3 η Proof Store handler — strict-equal query

File Line(s) Evidence
src/tools/merkle.ts 296-300 SELECT hash FROM thought_records WHERE session_id = ? ORDER BY rowid ASC — strict equality, no NULL coalescing, no JOIN through task_id.
src/tools/merkle.ts 321-323 if (rows.length === 0) throw new NoThoughtRecordsError(sid);
src/tools/merkle.ts 480-488 Handler maps NoThoughtRecordsError → MCP envelope {ok:false, error:{code:'ERR_NO_RECORDS', ...}}.

2.4 Hash subset — session_id is OUT of band

File Line(s) Evidence
src/domains/trail/schema.ts 172-187 computeHash reads exactly 6 fields: {id, type, task_id, content, timestamp, prev_hash}. agent_id, hash and session_id are all EXCLUDED.
src/db/migrations/006_eta.sql 26-28 Explicitly: “session_id is NOT part of the thought-record hash subset … Adding session_id after the fact does not invalidate existing chains.”

This is the load-bearing invariant the fix exploits: threading session_id through the createThoughtRecord path does NOT change the hash of any existing or future record, and does NOT invalidate audit_verify_chain.

3. Test surface that already proves part of the gap

src/__tests__/tools/merkle.test.ts line 91-94 has an explicit comment that documents the gap:

Insert a thought_record directly via SQL — bypasses createThoughtRecord because the P0.7.2 API does not expose session_id (it’s the new column added by 006_eta.sql). Used by finalize tests.

The insertRecord helper at merkle.test.ts:98-139 writes the 10-column row directly via SQL so that finalize tests can populate session_id non-NULL. The finalize tests currently pass only because they bypass the repository.

There is no test today that exercises the path audit_session_start → createThoughtRecord({session_id}) → finalizeMerkleRoot end-to-end. This is the regression hole R89.A closes.

4. Scope of the fix (per dispatch)

Minimal, hash-neutral. Five touch-points in src/domains/trail/repository.ts:

Location Edit
CreateThoughtRecordInput (line ~81) Add optional readonly session_id?: string.
CreateThoughtRecordInputSchema (line ~114) Add session_id: z.string().min(1).optional().
INSERT column list (line ~227) Append session_id (10 columns).
INSERT VALUES binding (line ~243) Append parsed.session_id ?? null.
Returned record shape (line ~254) Include session_id: parsed.session_id ?? null.

Plus two test additions:

  • src/__tests__/domains/trail/repository.test.ts — assert session_id is persisted when supplied (and the absence-case stays NULL, and Zod rejects empty-string).
  • src/__tests__/tools/merkle.test.ts — new describe block exercising the end-to-end chain audit_session_start → createThoughtRecord({session_id}) → finalizeMerkleRoot without bypassing the repository. Must assert no ERR_NO_RECORDS and that the returned record_count matches the supplied count.

5. What is explicitly NOT in scope

  • No change to the hash subset in computeHash (session_id stays excluded).
  • No change to audit_verify_chain — it already ignores session_id.
  • No widening of ThoughtRecord (the 8-field hash-chained shape exported by schema.ts) — session_id is repository-level metadata, not chain-level. Returning it from createThoughtRecord extends the function’s return shape but does not redefine the canonical ThoughtRecord type. (Implementation detail: the return type widens to ThoughtRecord & { session_id: string | null }, or the function gains a local return type — packet step picks the cleaner option.)
  • No change to listThoughtRecords, getThoughtRecord, or thought_record_list — out of scope. (Future R89+ work may extend them; not needed to fix merkle_finalize.)
  • No backfill migration — existing NULL rows stay NULL. The 003-migration test in merkle.test.ts:174-181 already proves the column tolerates NULL.

6. Diagnosis cross-check against R88.B documentation

R88.B’s colibri-verification SKILL.md edit (PR #222) added a “Common Verification Failures” row that names this exact failure mode and points at the parked task. R89.A is the resolution that task captured.

7. Verification plan preview (full in packet)

Gates: npm run build && npm run lint && npm test — all three must pass.

Expected new test count delta: +5 to +8 tests (3-4 in repository.test.ts for the repository signature, 3-4 in merkle.test.ts for the end-to-end chain).

No existing test should change. The hash-pinned snapshot at repository.test.ts:186-196 MUST stay green — it is the load-bearing proof that the hash subset is unaffected.


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.