R89.A verification — test evidence
1. Gate results
All three required gates green at HEAD 2d15d678.
1.1 npm run build
> colibri@0.0.1 build
> tsc
> colibri@0.0.1 postbuild
> node scripts/copy-migrations.mjs
copy-migrations: copied 6 migration(s) E:\AMS\.worktrees\claude\r89-a-merkle-session-binding\src\db\migrations -> E:\AMS\.worktrees\claude\r89-a-merkle-session-binding\dist\db\migrations
Zero TypeScript errors. The cast at repository.ts:418 bridges
exactOptionalPropertyTypes: true (Zod’s optional() infers
session_id: string | undefined but CreateThoughtRecordInput uses
session_id?: string). The cast follows the same pattern as the existing
audit_session_start handler at src/tools/merkle.ts:429.
1.2 npm run lint
> colibri@0.0.1 lint
> eslint src
Zero lint findings.
1.3 npm test
Test Suites: 47 passed, 47 total
Tests: 2421 passed, 2421 total
Snapshots: 0 total
Time: 30.08 s
Delta vs. base (fab4bf57): 2406 → 2421 = +15 new tests.
| Source | Suite | Pre-R89.A | Post-R89.A | Delta |
|---|---|---|---|---|
src/__tests__/domains/trail/repository.test.ts |
trail repository | 48 | 58 | +10 |
src/__tests__/tools/merkle.test.ts |
merkle tools | 43 | 48 | +5 |
| All other suites | (unchanged) | 2315 | 2315 | 0 |
| Total | 2406 | 2421 | +15 |
No pre-existing test regressed. The pinned-hash snapshot at
repository.test.ts:186-196 continues to assert
6a2f9597f563d5515cfa69891a51806d0f93bfbe222997d3ba37c365ceee3f1a — proving
that adding session_id to the input/persist path does NOT change the
6-field hash subset.
2. New test coverage — acceptance proof
2.1 repository.test.ts — 10 new tests
createThoughtRecord — session_id binding (R89.A)
√ persists session_id when supplied (SQL readback)
√ returns session_id on the created record
√ writes NULL session_id when omitted (donor-era default)
√ rejects empty-string session_id via Zod
√ does NOT change the pinned hash (session_id is out of band)
√ chain linkage (prev_hash) is unchanged by session_id (per-task_id, not per-session)
getThoughtRecord — session_id round-trip (R89.A)
√ returns session_id when row has one
√ returns null session_id when row has none
listThoughtRecords — session_id round-trip (R89.A)
√ returns session_id on every listed record
√ returns null session_id alongside bound records (mixed list)
2.2 merkle.test.ts — 5 new tests
finalizeMerkleRoot — end-to-end session_id binding (R89.A)
√ audit_session_start → createThoughtRecord({session_id}) → finalizeMerkleRoot returns a root (no ERR_NO_RECORDS)
√ records bound to a different session_id are NOT counted (cross-session isolation)
√ thought records created WITHOUT session_id are NOT collected for any session
√ record_count equals exactly the number of bound thought_records
√ Merkle root over bound records equals manual buildMerkleTree(hashes) computation
The first test is the load-bearing acceptance: it runs the canonical
CLAUDE.md §7 chain WITHOUT bypassing the repository (no
insertRecord SQL helper). Pre-R89.A this chain threw
NoThoughtRecordsError; post-R89.A it returns a real root.
3. Invariants from the contract — verified
| # | Invariant | Verification |
|---|---|---|
| I1 | Hash subset stays 6-field {id, type, task_id, content, timestamp, prev_hash}. |
schema.ts unchanged; pinned-hash test green. |
| I2 | Pinned-hash snapshot at repository.test.ts:186-196 returns 6a2f9597…. |
Green (test ran in the 58/58 suite). |
| I3 | audit_verify_chain continues to ignore session_id. |
verifier.ts unchanged; verifier.test.ts 26/26 green. |
| I4 | Per-task_id chain linkage unchanged. |
New test “chain linkage (prev_hash) is unchanged by session_id (per-task_id, not per-session)” — green. |
| I5 | Pre-existing NULL rows not migrated. | 006_eta.sql is unmodified; ALTER stays nullable; no backfill. |
| I6 | MCP envelope shape is additive. | Handler return type widened to ThoughtRecordWithSession; envelope structure unchanged. |
| I7 | task_update writeback gate continues to function. |
writeback.test.ts unchanged-by-shape; 100% pass after fixture update. |
4. New invariants — proven
| # | Invariant | Test that proves it |
|---|---|---|
| N1 | When supplied, session_id is persisted. |
persists session_id when supplied (SQL readback). |
| N2 | When omitted, session_id is NULL. |
writes NULL session_id when omitted (donor-era default). |
| N3 | Empty string rejected. | rejects empty-string session_id via Zod. |
| N4 | merkle_finalize returns a root, no ERR_NO_RECORDS. |
audit_session_start → createThoughtRecord({session_id}) → finalizeMerkleRoot returns a root. |
| N5 | record_count matches the supplied count. |
record_count equals exactly the number of bound thought_records. |
| N6 | Cross-session isolation. | records bound to a different session_id are NOT counted (cross-session isolation). |
Plus an extra invariant not enumerated in the contract but verified here:
| # | Invariant | Test |
|---|---|---|
| Extra | Merkle root computed by merkle_finalize equals buildMerkleTree(hashes) over the same hashes. |
Merkle root over bound records equals manual buildMerkleTree(hashes) computation. |
5. Files changed
| File | LOC added | LOC removed | Notes |
|---|---|---|---|
src/domains/trail/repository.ts |
~50 | ~12 | Public types, Zod, 4× SELECT widening, 10-column INSERT, handler return types. |
src/__tests__/domains/trail/repository.test.ts |
~140 | ~12 | New describe blocks + fixture update. |
src/__tests__/domains/trail/verifier.test.ts |
~12 | ~12 | Fixture update only. |
src/__tests__/domains/tasks/writeback.test.ts |
~14 | ~3 | Fixture update only. |
src/__tests__/domains/proof/retention.test.ts |
~6 | ~1 | Fixture update only. |
src/__tests__/tools/merkle.test.ts |
~155 | ~1 | New describe block. |
docs/audits/r89-a-merkle-session-binding-audit.md |
150 | 0 | Step 1. |
docs/contracts/r89-a-merkle-session-binding-contract.md |
140 | 0 | Step 2. |
docs/packets/r89-a-merkle-session-binding-packet.md |
212 | 0 | Step 3. |
docs/verification/r89-a-merkle-session-binding-verification.md |
(this file) | 0 | Step 5. |
6. Commit chain
| Step | SHA | Subject |
|---|---|---|
| 1 | c2a6d124 |
audit(r89-a-merkle-session-binding): inventory surface |
| 2 | bba94a62 |
contract(r89-a-merkle-session-binding): behavioral contract |
| 3 | 041fecbe |
packet(r89-a-merkle-session-binding): execution plan |
| 4 | 2d15d678 |
feat(r89-a-merkle-session-binding): thread session_id through createThoughtRecord |
| 5 | (this commit) | verify(r89-a-merkle-session-binding): test evidence |
Base: fab4bf57 (main).
7. Acceptance summary
All five contract acceptance criteria met:
- A1 ✓
createThoughtRecordaccepts optionalsession_id, persists when supplied, writesNULLotherwise. - A2 ✓ All seven I-invariants hold (pinned-hash + verifier suite green).
- A3 ✓ All six N-invariants proven by new tests.
- A4 ✓
npm run build,npm run lint,npm testall green. - A5 ✓ Test count strictly increased: 2406 → 2421.
The canonical CLAUDE.md §7 proof-grade chain now returns a Merkle root
rather than ERR_NO_RECORDS. The R88.B failure-mode row in the
colibri-verification SKILL.md remains correct as a historical
documentation artifact; a separate hygiene task may close it once R89.A
is observed green in a live MCP session.
8. Out-of-scope (not done in R89.A)
- No backfill of pre-existing NULL
session_idrows. - No documentation update to
colibri-verificationSKILL.md (separate hygiene task — would supersede the R88.B failure-mode row). - No change to
audit_verify_chain(the chain is per-task_idand remains so). - No new MCP tool. The
thought_recordtool signature gains an optional field; existing clients ignore it. - No reverse-index from
session_idtotask_id(the two scopes stay independent).