R89.A execution packet — what changes, in what order
1. Source edits — src/domains/trail/repository.ts
All line numbers refer to the file at base commit fab4bf57.
1.1 Edit A — CreateThoughtRecordInput interface (~line 81-86)
Add an optional session_id field to the input interface. JSDoc receives a
short note that the field is opt-in and NULL when absent — preserving the
donor-era behaviour for callers that do not opt in.
1.2 Edit B — CreateThoughtRecordInputSchema Zod object (~line 114-119)
Add session_id: z.string().min(1).optional(). Min(1) so empty strings are
rejected (consistent with task_id and agent_id non-empty constraints,
prevents accidental “” passing through and matching WHERE session_id = ?
against the empty-string).
1.3 Edit C — ThoughtRecordRow internal type (~line 143-152)
Add readonly session_id: string | null so the row-shape returned from
better-sqlite3 after SELECT can carry the new column.
1.4 Edit D — rowToRecord mapper (~line 160-171)
Widen the function’s return signature and emit the new field. The canonical
ThoughtRecord type in schema.ts stays 8-field; rowToRecord will return
ThoughtRecord & { session_id: string | null } (a structural superset).
1.5 Edit E — INSERT statement column list (~line 226-230)
Append session_id to the 9-column list, making it 10 columns. Add a 10th
positional placeholder to the VALUES clause.
1.6 Edit F — INSERT VALUES binding (~line 243-253)
Append a 10th positional argument: parsed.session_id ?? null. SQLite accepts
JavaScript null and stores it as SQL NULL.
1.7 Edit G — createThoughtRecord return shape (~line 254-263)
Include session_id: parsed.session_id ?? null in the returned object.
Function return signature widens correspondingly to
ThoughtRecord & { session_id: string | null }.
1.8 Edit H — getThoughtRecord SELECT (~line 284-289)
Append , session_id to the SELECT column list so the row carries the new
field. Return type widens as in Edit D.
1.9 Edit I — listThoughtRecords four SELECT statements (~line 318-344)
Each of the four prepared statements gains , session_id in its SELECT list.
Function return type widens to
(ThoughtRecord & { session_id: string | null })[].
1.10 Edit J — module-level type alias
Introduce a local helper type for readability:
type ThoughtRecordWithSession = ThoughtRecord & { session_id: string | null };
Use it as the return-type annotation on createThoughtRecord,
getThoughtRecord, listThoughtRecords, and rowToRecord. Exported so tests
can import it.
2. Read-path consistency
After the source edits, all three reader functions (createThoughtRecord,
getThoughtRecord, listThoughtRecords) return the same widened shape. Three
downstream consumers must continue to work:
| Consumer | Why it’s safe |
|---|---|
verifyChain in verifier.ts:119 |
Reads only the 6-field hash subset + hash + prev_hash. Treats the extra field as opaque — TypeScript structural subtyping permits the widened input. |
thought_record MCP tool handler in repository.ts:377 |
Returns the function’s output verbatim. α middleware wraps it in {ok:true, data:...}. Clients gain a new optional field; existing clients ignore it. |
thought_record_list MCP tool handler in repository.ts:389-398 |
Same as above. |
No edit to verifier.ts. No edit to the MCP tool handlers — only the inferred
output shape widens.
3. Test additions
3.1 src/__tests__/domains/trail/repository.test.ts
New describe block immediately after the existing “createThoughtRecord — explicit parent via sequence” block (~line 232):
describe('createThoughtRecord — session_id binding (R89.A)', () => {
it('persists session_id when supplied', () => { … SQL readback … });
it('returns session_id on the created record', () => { … });
it('writes NULL when session_id is omitted', () => { … });
it('rejects empty-string session_id via Zod', () => { … });
it('does NOT change the pinned hash (session_id is out of band)', () => {
// Repeats the pinned-hash snapshot test BUT with session_id supplied.
// Asserts the hash matches '6a2f9597…' — proves invariant I1 + I2.
});
});
Plus a small inline addition to “getThoughtRecord” / “listThoughtRecords” describes to read back the session_id round-trip:
it('round-trips session_id through getThoughtRecord (R89.A)', () => { … });
it('round-trips session_id through listThoughtRecords (R89.A)', () => { … });
3.2 src/__tests__/tools/merkle.test.ts
New describe block placed after the existing “finalizeMerkleRoot — acceptance test (5 records)” section. The goal is the end-to-end chain that the dispatch named:
describe('finalizeMerkleRoot — end-to-end session_id binding (R89.A)', () => {
it('audit_session_start → createThoughtRecord({session_id}) → finalizeMerkleRoot returns a root, no ERR_NO_RECORDS', () => {
// Uses createThoughtRecord (NOT the SQL-bypass `insertRecord` helper).
// Asserts: result.record_count === 3, result.root is 64-hex.
});
it('records bound to a different session_id are not counted', () => {
// Creates 2 records on session A, 3 on session B, finalizes A.
// Asserts: result.record_count === 2 for A; B remains unfinalized.
});
it('thought records created without session_id are NOT collected for any session', () => {
// Creates 2 unbound records, finalize on an empty session ⇒ throws NoThoughtRecordsError.
});
});
These tests do NOT bypass the repository. They prove the canonical chain returns a Merkle root.
4. Lines-of-code budget
Estimated additions:
| File | LOC |
|---|---|
src/domains/trail/repository.ts |
~25 (input, Zod, ThoughtRecordRow, rowToRecord, INSERT cols+vals, return shape, getThoughtRecord SELECT, 4× listThoughtRecords SELECTs, helper alias) |
src/__tests__/domains/trail/repository.test.ts |
~80 (new describe + small additions) |
src/__tests__/tools/merkle.test.ts |
~70 (new describe) |
| Source files total | ~175 LOC additions |
(The dispatch’s “~25 LOC across 3 files” referred to production code; tests are additive.)
5. Build / lint / test plan
cd .worktrees/claude/r89-a-merkle-session-bindingnpm install— only ifnode_modulesabsent (worktree copies it).- Implement edits A-J in
repository.tsfirst. npm run build— must compile cleanly. TypeScript will surface any type-widening miss.- Write the repository.test.ts new describe block + small additions.
npm test src/__tests__/domains/trail/repository.test.ts— must pass, including the pinned-hash snapshot.- Write the merkle.test.ts new describe block.
npm test src/__tests__/tools/merkle.test.ts— must pass.npm test— full suite. Expected delta: starting count 2406 → 2414-2418. No pre-existing test should regress.npm run lint— must be clean.npm run build && npm run lint && npm test— final gate.
6. Risk register
| Risk | Probability | Mitigation |
|---|---|---|
.toEqual(rec) assertions at repository.test.ts:240, 288, 526 break because rec gains the session_id: null field while fetched doesn’t. |
Low after Edits H + I (both readers also include session_id). |
Edits H + I make all three accessors return the same widened shape. |
| Pinned hash snapshot regresses. | None — computeHash is unchanged and only sees the 6-field subset. |
Test-asserted in §3.1. |
verifyChain rejects the widened input. |
None — TypeScript structural subtyping accepts a superset; runtime reads only canonical fields. | Existing tests prove this remains green. |
| MCP tool envelope shape becomes incompatible with existing clients. | None — the new field is additive and optional. | Tool docstring stays unchanged. |
| Migration ordering hazard (a fresh DB without 006_eta missing the column). | None — both repository.test.ts and merkle.test.ts already apply 006_eta in test setup, and the live runtime runs migrations in order. | Existing migration tests at merkle.test.ts:159-214 pin the column’s existence. |
7. Commit sequence
| Step | File(s) | Commit message |
|---|---|---|
| 1 | docs/audits/r89-a-merkle-session-binding-audit.md |
audit(r89-a-merkle-session-binding): inventory surface (shipped c2a6d124) |
| 2 | docs/contracts/r89-a-merkle-session-binding-contract.md |
contract(r89-a-merkle-session-binding): behavioral contract (shipped bba94a62) |
| 3 | docs/packets/r89-a-merkle-session-binding-packet.md |
packet(r89-a-merkle-session-binding): execution plan |
| 4 | src/domains/trail/repository.ts + test files |
feat(r89-a-merkle-session-binding): thread session_id through createThoughtRecord |
| 5 | docs/verification/r89-a-merkle-session-binding-verification.md |
verify(r89-a-merkle-session-binding): test evidence |
8. Out-of-scope (intentionally not done in R89.A)
- No backfill of NULL
session_idrows. Out of scope. - No reverse-index from
task_idtosession_id. The two scopes remain independent. - No new MCP tool.
thought_recordkeeps the same name and protocol. - No change to
audit_verify_chainbehaviour. - No new migration. The schema is already correct as of
006_eta.sql. - No update to
colibri-verificationSKILL.md to remove the “Common Verification Failures” row added in R88.B. R88.B’s documentation row remains correct as a historical artifact and a how-to recover note. A separate R89+ hygiene task may close it once R89.A lands and is observed green in a live MCP session.