Packet: P0.8.3 η Merkle Root Finalization Tools
Task: P0.8.3 — execution plan
Contract: docs/contracts/merkle-tools-contract.md
Audit: docs/audits/merkle-tools-audit.md
1. File-by-file plan
1.1 New: src/db/migrations/006_eta.sql
Create file with the exact body from contract §6. No TypeScript — pure SQL.
After write, npm test should apply the migration automatically on in-memory
databases because the test helpers load the .sql files directly
(src/__tests__/domains/trail/repository.test.ts:52-64 shows the pattern).
1.2 New: src/tools/merkle.ts
Module layout:
1. File header (doc block)
2. Imports: node:crypto, zod, better-sqlite3 types, db/index (getDb), server (registerColibriTool, ColibriServerContext),
domains/proof/merkle (buildMerkleTree, EMPTY_TREE_ROOT, MerkleProof unused but re-exported if needed)
3. Types: AuditSession, MerkleRootRow, handler error shapes
4. Zod schemas (3 inputs)
5. Repository-style functions:
- startAuditSession(db, input, options) → AuditSession
- finalizeMerkleRoot(db, session_id, options) → MerkleRootRow
- getMerkleRoot(db, session_id) → MerkleRootRow | null
6. Helpers:
- fetchSessionHashes(db, session_id) → string[]
- readAuditSession(db, session_id) → AuditSession | null
7. Error classes (3): AuditSessionNotFoundError, AuditSessionExistsError, AlreadyFinalizedError
8. registerMerkleTools(ctx) — wires all 3 tools
All functions take db as first argument for testability. The MCP handlers lazy-resolve
getDb() at call time.
1.3 New: src/__tests__/tools/merkle.test.ts
Test organization (one describe per concern, ~35 tests total):
describe('migration 005 — schema shape')
describe('startAuditSession — happy path')
describe('startAuditSession — idempotence / uniqueness')
describe('finalizeMerkleRoot — happy path')
describe('finalizeMerkleRoot — AC4 zero-record rejection')
describe('finalizeMerkleRoot — already-finalized rejection')
describe('finalizeMerkleRoot — unknown session rejection')
describe('finalizeMerkleRoot — 5-record manual computation (AC)')
describe('finalizeMerkleRoot — order independence')
describe('getMerkleRoot — happy path')
describe('getMerkleRoot — unfinalized session')
describe('registerMerkleTools — registration')
describe('Zod schema validation')
1.4 Edit: src/server.ts
Add one import and one call in bootstrap():
+import { registerMerkleTools } from './tools/merkle.js';
...
registerTaskTools(ctx);
+ // P0.8.3: register η Proof Store tools — audit_session_start, merkle_finalize, merkle_root.
+ registerMerkleTools(ctx);
await start(ctx);
1.5 Edit: src/db/schema.sql
Add to the existing η section (-- P0.8 η Proof Store → migrations/006_eta.sql):
-- η Proof Store — introduced by 006_eta.sql (P0.8.3).
-- audit_sessions — one row per audit_session_start call. PK: session_id.
-- Columns: intent, task_id (opaque), started_at.
-- merkle_roots — one row per finalized session. PK: session_id.
-- Columns: root (64-char hex), record_count, finalized_at.
-- thought_records.session_id (new nullable column) — groups records into
-- audit sessions for merkle_finalize's collection query.
Pure commentary — schema.sql is not executed.
2. Test plan (exhaustive)
2.1 Migration 005 schema shape
| Test | Assertion |
|---|---|
005 adds audit_sessions table |
sqlite_master contains audit_sessions |
005 adds merkle_roots table |
sqlite_master contains merkle_roots |
005 adds session_id column to thought_records |
PRAGMA table_info('thought_records') returns a row for session_id |
005 adds idx_trail_session index |
sqlite_master contains idx_trail_session |
2.2 startAuditSession (repository function)
| Test | Assertion |
|---|---|
| returns a UUID-shaped session_id | matches /^[0-9a-f-]{36}$/i |
| persists exactly one row | SELECT COUNT(*) = 1 |
| writes intent verbatim | row.intent === input.intent |
| task_id optional | task_id: null when not provided |
started_at is ISO-8601 |
parses + re-serializes to same string |
| accepts injected session_id (test seam) | returned matches provided |
| rejects duplicate session_id | second call with same id throws AuditSessionExistsError |
2.3 finalizeMerkleRoot
| Test | Assertion |
|---|---|
happy path — writes merkle_roots row |
SELECT COUNT(*) FROM merkle_roots = 1 |
returns root matching manual buildMerkleTree |
equal hex string |
returns record_count equal to inserted records |
integer |
returns finalized_at ISO-8601 |
parses successfully |
AC4: zero records → throws ERR_NO_RECORDS |
error.code === ‘ERR_NO_RECORDS’ |
unknown session → throws ERR_SESSION_NOT_FOUND |
error.code matches |
already finalized → throws ERR_ALREADY_FINALIZED |
second call errors |
| AC5: 5-record session → root matches manual computation | headline acceptance test |
| order independence — 5 records inserted in reverse → same root | equality |
uses ORDER BY rowid ASC not created_at |
two records with identical timestamp still produce deterministic root |
2.4 getMerkleRoot
| Test | Assertion |
|---|---|
| happy path returns all 4 fields | session_id, root, record_count, finalized_at |
| unfinalized session returns null | getMerkleRoot(...) === null |
2.5 MCP handler envelope shape (called via InMemoryTransport)
| Test | Assertion |
|---|---|
audit_session_start returns {ok:true, data:{session_id, ...}} |
JSON wire envelope |
merkle_finalize on zero records returns {ok:false, error:{code:'ERR_NO_RECORDS',...}} |
|
merkle_root on unfinalized returns {ok:false, error:{code:'ERR_NOT_FINALIZED',...}} |
2.6 Tool registration
| Test | Assertion |
|---|---|
registerMerkleTools(ctx) adds 3 names to ctx._registeredToolNames |
Set has all 3 |
| second call throws | duplicate-name guard triggers |
| server_ping + server_health still work in the same ctx | no collision |
2.7 Zod validation
| Test | Assertion |
|---|---|
empty intent rejected |
safeParse fails |
empty session_id rejected |
safeParse fails |
wrong type on task_id rejected |
safeParse fails |
3. Migration plan
3.1 Apply path
initDb(path)insrc/db/index.tsreads everyNNN_*.sqlinsrc/db/migrations/, sorts numerically, applies in a transaction per file while bumpingPRAGMA user_version.- Adding
006_eta.sqlas the 5th migration file causesuser_versionto advance from 4 → 5 on next boot. - Migration is idempotent: re-running the same
initDb(path)is a no-op (step skipped becauseuser_version = 5).
3.2 Test-suite path
- Test helpers pattern: read
.sqlfile viafs.readFileSync, pass todb.exec(). The new test file MUST read ALL prior migrations + 005 together, in the right order: 002 → 003 → 004 → 005. (In-memory tests do not go throughinitDb; they hand-load only the migrations they need.) - Because
006_eta.sqlhas anALTER TABLE thought_records ADD COLUMN, it depends on003_thought_records.sqlbeing applied first. The test helper enforces that order.
3.3 No migration backtracking
- SQLite has no
DROP COLUMNin this version; once shipped,session_idis permanent onthought_records. This is fine — the column is nullable and the P0.7.2 API ignores it.
4. Rollback strategy
4.1 Per-commit rollback
- Each of the 5 steps (audit / contract / packet / implement / verify) is a
separate commit. Rolling back a step is a
git revert <sha>away.
4.2 Migration rollback
- We do NOT ship a down-migration. If
006_eta.sqllands in production and needs removal:- New migration
006_drop_eta.sqlthat drops the two tables + the index. - The
session_idcolumn onthought_recordscannot be dropped (SQLite limitation) — it stays as a harmless NULL-column. This is acceptable.
- New migration
4.3 Tool-registration rollback
- If the PR lands and then needs to be rolled back without a schema revert,
simply revert the
src/server.tschange. The migration stays in place; the tables become dormant (no code reads/writes them). No functional impact.
4.4 Data integrity under partial rollback
merkle_rootsentries are immutable by design. A rollback of the tool code does not invalidate any finalized root already persisted — it just makes it unreadable via MCP until the tool is re-registered.
5. Implementation order (commit 4 — feat(p0-8-3))
Single commit with all code + migration + test:
- Write
src/db/migrations/006_eta.sql. - Write
src/tools/merkle.ts. - Edit
src/server.ts(import + call). - Edit
src/db/schema.sql(commentary). - Write
src/__tests__/tools/merkle.test.ts. - Run
npm run lint:fixif available, thennpm testlocally. - If green, commit.
- If red, iterate until green — each code-diff is edit+re-run, single commit at end.
6. Risk register
| R | Risk | Mitigation |
|---|---|---|
| R-1 | ALTER TABLE in SQLite is restrictive |
Only ADD COLUMN used — fully supported in every SQLite ≥3.2.0 (better-sqlite3 ships far newer). |
| R-2 | Test flake from CLI rowid vs. created_at | All queries use ORDER BY rowid ASC explicitly. |
| R-3 | merkletreejs sort options forgotten |
We consume P0.8.1’s buildMerkleTree which already sets both sortPairs: true + sortLeaves: true. No direct merkletreejs import in this task. |
| R-4 | Concurrent merkle_finalize calls produce duplicate rows |
Each finalize runs inside db.transaction. The PK UNIQUE on merkle_roots.session_id short-circuits the second call to ERR_ALREADY_FINALIZED. |
| R-5 | getDb() called at tool registration time (not call time) |
Pattern copied from src/domains/trail/repository.ts:377 — handler body calls getDb(), not the outer closure. |
| R-6 | Cross-worktree file leak (Wave C lesson) | git status was clean before Step 1; every edit is inside the worktree; no main-checkout edits. |
| R-7 | Jest + Zod mocking hazard | We do not mock Zod — we only use it as documented. jest.isolateModulesAsync is not used anywhere in this test file. |
| R-8 | Lint rule on :memory: connection leak |
We add afterEach(() => db.close()) in every describe that opens a DB. |
| R-9 | Writeback ordering violation in PR body | The PR body records the summary BEFORE any theoretical merkle_finalize call. There is no live MCP client during this executor run, so the ordering is trivially satisfied. |
7. Commit plan
| # | Commit message | Files |
|---|---|---|
| 1 | audit(merkle-tools): inventory surface |
docs/audits/merkle-tools-audit.md |
| 2 | contract(merkle-tools): behavioral contract |
docs/contracts/merkle-tools-contract.md |
| 3 | packet(merkle-tools): execution plan |
docs/packets/merkle-tools-packet.md (this file) |
| 4 | feat(p0-8-3): η merkle tools — merkle_finalize/merkle_root/audit_session_start |
src/tools/merkle.ts, src/__tests__/tools/merkle.test.ts, src/db/migrations/006_eta.sql, src/server.ts, src/db/schema.sql |
| 5 | verify(merkle-tools): test evidence |
docs/verification/merkle-tools-verification.md |