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_idexists at the DB layer (added by migration006_eta.sql).merkle_finalize’s collection query strict-equals on that column.- But
createThoughtRecord(the only path thethought_recordMCP tool calls) silently dropssession_id— it is not in the Zod input schema, not in the function’sCreateThoughtRecordInputtype, 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
createThoughtRecordbecause the P0.7.2 API does not exposesession_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— assertsession_idis 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 chainaudit_session_start → createThoughtRecord({session_id}) → finalizeMerkleRootwithout bypassing the repository. Must assert noERR_NO_RECORDSand that the returnedrecord_countmatches the supplied count.
5. What is explicitly NOT in scope
- No change to the hash subset in
computeHash(session_idstays excluded). - No change to
audit_verify_chain— it already ignoressession_id. - No widening of
ThoughtRecord(the 8-field hash-chained shape exported byschema.ts) —session_idis repository-level metadata, not chain-level. Returning it fromcreateThoughtRecordextends the function’s return shape but does not redefine the canonicalThoughtRecordtype. (Implementation detail: the return type widens toThoughtRecord & { session_id: string | null }, or the function gains a local return type — packet step picks the cleaner option.) - No change to
listThoughtRecords,getThoughtRecord, orthought_record_list— out of scope. (Future R89+ work may extend them; not needed to fixmerkle_finalize.) - No backfill migration — existing NULL rows stay NULL. The 003-migration test
in
merkle.test.ts:174-181already 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.