Audit: P0.8.3 η Merkle Root Finalization Tools
Task: P0.8.3 — η Proof Store MCP surface (3 tools: audit_session_start, merkle_finalize, merkle_root)
Branch: feature/p0-8-3-merkle-tools
Base commit: dc660381 (main at PR #138 merge)
Date: 2026-04-17
Auditor: T3 Executor (Claude Opus 4.7)
Depends on: P0.8.1 (primitives SHIPPED in PR #136 at src/domains/proof/merkle.ts)
1. Surface inventory
1.1 Existing η state (before this task)
| Path | Exists? | Role |
|---|---|---|
src/domains/proof/merkle.ts |
yes (P0.8.1) | Pure primitives: buildMerkleTree, generateProof, verifyProof, EMPTY_TREE_ROOT |
src/domains/proof/ other files |
no | None yet |
src/tools/merkle.ts |
no | target of this task |
src/__tests__/tools/merkle.test.ts |
no | target of this task |
src/db/migrations/005_*.sql |
no | target of this task — η earns its tables here per schema.sql ownership map |
1.2 P0.8.1 primitives this task consumes
From src/domains/proof/merkle.ts (read-only — must not modify):
export const EMPTY_TREE_ROOT: string; // sha256('') = e3b0c4...b855
export function buildMerkleTree(recordHashes: string[]): { root, tree };
export function generateProof(tree, leafHash): MerkleProof;
export function verifyProof(root, proof, leafHash): boolean;
Options { sortPairs: true, sortLeaves: true } guarantee order-independence — confirmed at
src/domains/proof/merkle.ts:116. No need to flag P0.8.1.
1.3 ζ Decision Trail integration points
The η finalize flow reads rows from the ζ thought_records table (owned by migration
003_thought_records.sql). Only the hash column is needed — we do NOT read chain linkage,
we just collect all hashes for a given session.
Critical: P0.7.2 lesson — order by rowid ASC, never by created_at
(millisecond-precision ties corrupted chains on fast Linux CI). Confirmed at
src/domains/trail/repository.ts:220,321,328,334,340.
Existing helpers available for import/reuse:
import Database from 'better-sqlite3'— db handle typelistThoughtRecords(db, {task_id, limit})exists atsrc/domains/trail/repository.ts:307, but filters bytask_id, notsession_id. We cannot reuse it directly since a session is not a task. See §2.2 on the audit_sessions table design.
1.4 Session identity — there is no session_id on thought records today
The current thought_records schema (migration 003) has 9 columns:
id, type, task_id, agent_id, content, timestamp, prev_hash, hash, created_at.
There is no session_id column. P0.7.1/P0.7.2 did not introduce one. So a
“session’s records” must be inferred some other way. Two candidate designs:
| Design | Mechanism | Trade-off |
|---|---|---|
A. Use task_id as session key |
Call merkle_finalize(session_id=...) where the caller passes a task id |
Simple, but conflates “audit session” with “task”; multiple sessions per task becomes impossible |
B. Add session_id column + new audit_sessions table |
New migration adds session_id TEXT to thought_records and a new audit_sessions table |
More work, but matches the CLAUDE.md §7 writeback protocol that explicitly mentions session_id |
Chosen: Design B — the task explicitly demands audit_session_start returns a fresh
session_id, and the 5-record test implies those 5 records must be grouped by a
session identity distinct from task. The new migration will:
- Create
audit_sessionstable. - Create
merkle_rootstable. - Add
session_id TEXT NULLcolumn tothought_records. - Create index
idx_trail_session ON thought_records(session_id, rowid)for the fast collection query.
The session_id column is nullable because the existing P0.7.2 API does not require it,
and we preserve backward compatibility with every record already in flight.
1.5 MCP tool registration pattern (from P0.3.4 / PR #137)
src/server.ts:558-564:
registerThoughtTools(ctx);
registerSkillTools(ctx);
registerTaskTools(ctx);
Each registerXxxTools(ctx) is defined in its domain’s repository and calls
registerColibriTool(ctx, name, config, handler) internally. The handler
lazy-resolves getDb() at call time so Phase 2 DB-open can precede any invocation.
This task will add one line: registerMerkleTools(ctx), defined in src/tools/merkle.ts.
1.6 Test directory convention
Existing under src/__tests__/:
config.test.ts
db-init.test.ts
domains/{proof,skills,tasks,trail}/
modes.test.ts
server.test.ts
shutdown.test.ts
smoke.test.ts
startup.test.ts
task-state-machine.test.ts
tools/health.test.ts
trail-schema.test.ts
Our new test at src/__tests__/tools/merkle.test.ts sits beside health.test.ts —
same style. Note the prompt warned the original task-breakdown.md path tests/tools/
is wrong; confirmed the repo convention is src/__tests__/tools/.
1.7 Migration numbering
ls src/db/migrations/ returns:
001_init.sql
002_tasks.sql
003_thought_records.sql
004_skills.sql
Next available prefix is 005. The schema.sql header at line 10 already reserves
migrations/006_eta.sql for η — matches our plan. Filename will be 006_eta.sql.
2. Files this task will create or modify
2.1 Files to create
| Path | Purpose |
|---|---|
src/tools/merkle.ts |
Main implementation — exports registerMerkleTools + repo functions for testability |
src/__tests__/tools/merkle.test.ts |
Full test suite (5-record manual-computation included) |
src/db/migrations/006_eta.sql |
Creates audit_sessions + merkle_roots; adds session_id column + index to thought_records |
docs/audits/merkle-tools-audit.md |
this file |
docs/contracts/merkle-tools-contract.md |
Step 2 |
docs/packets/merkle-tools-packet.md |
Step 3 |
docs/verification/merkle-tools-verification.md |
Step 5 |
2.2 Files to modify
| Path | Change |
|---|---|
src/server.ts |
Add one import + one registerMerkleTools(ctx) line after registerTaskTools(ctx) |
src/db/schema.sql |
Add commentary block describing audit_sessions + merkle_roots under the η section |
2.3 Files NOT to touch (guardrails)
Per the task prompt §7 constraints:
src/domains/proof/merkle.ts— consume only, do not modifysrc/domains/proof/retention.ts— P0.8.2 territory (does not exist yet)src/domains/integrations/notifications.ts— P0.9.3 territory (does not exist yet)
3. Tables this task will add or read
3.1 Read-only usage
| Table | Usage |
|---|---|
thought_records |
Read hash column ordered by rowid ASC where session_id = ? — used by merkle_finalize |
3.2 New tables (migration 005)
audit_sessions
| Column | Type | Notes |
|——–|——|——-|
| session_id | TEXT PRIMARY KEY | UUID v4 minted by audit_session_start |
| intent | TEXT NOT NULL | Caller-supplied human-readable purpose |
| task_id | TEXT | Optional linkage to a β task; no FK (β soft-delete makes FK unsafe) |
| started_at | TEXT NOT NULL | ISO-8601 UTC |
Index: idx_audit_sessions_task ON audit_sessions(task_id) — supports future cross-task session reports.
merkle_roots
| Column | Type | Notes |
|——–|——|——-|
| session_id | TEXT PRIMARY KEY | 1:1 to audit_sessions — each session finalizes at most once |
| root | TEXT NOT NULL | 64-char lowercase hex SHA-256 (or EMPTY_TREE_ROOT for zero-record session — but zero-record is rejected per AC4) |
| record_count | INTEGER NOT NULL | Non-negative count of hashes in the tree |
| finalized_at | TEXT NOT NULL | ISO-8601 UTC |
No FK to audit_sessions — the pk-matching intent is enforced via handler logic, and
the two tables live in the same migration. FK constraints are sparingly used in Colibri
Phase 0 per the schema.sql header precedent.
3.3 Altered tables (migration 005)
thought_records — add nullable session_id
ALTER TABLE thought_records ADD COLUMN session_id TEXT;
CREATE INDEX idx_trail_session ON thought_records(session_id);
(rowid is an implicit tie-breaker in SQLite and cannot appear in a
CREATE INDEX column list. A session-scoped ORDER BY rowid ASC query
still uses this index for the equality predicate and returns rows in rowid
order via SQLite’s natural per-session scan.)
SQLite ALTER TABLE ADD COLUMN is safe: it appends a new column with NULL default and
no data rewrite. Existing rows get session_id = NULL. The P0.7.2 tools do not need to
change — they ignore the new column. The P0.8.3 handlers read WHERE session_id = ?
so NULL rows never match a real session lookup.
4. Test plan (detailed in the contract — listed here for inventory)
The 5-record acceptance test is the headline. Additional tests:
audit_session_startreturns a valid UUID + persists anaudit_sessionsrow.merkle_finalizerejects a session with zero thought records (AC4).merkle_finalizewith a pre-set (injected) session_id writesmerkle_rootsand returns the correct root.- 5-record session: create 5 records with known hashes, call
merkle_finalize, compute expected root manually viabuildMerkleTree, assert equality. (Acceptance test.) merkle_finalizeis idempotent / rejected-on-replay — second call with the same session_id throwsERR_ALREADY_FINALIZED(PK UNIQUE).merkle_rootreturns{ root, record_count, finalized_at }for a finalized session.merkle_rootreturnsERR_NOT_FINALIZEDfor a session with nomerkle_rootsrow.- Tool registration:
registerMerkleToolsregisters exactly 3 names; second call throws via the duplicate guard. - Zod schema rejects empty
session_id. thought_recordinsert withsession_idstill satisfies the hash-chain invariants (we do NOT includesession_idin the hash subset — it remains authorship-adjacent metadata, not chain-integrity data).
5. Canonical references
docs/guides/implementation/task-breakdown.md § P0.8.3docs/reference/extractions/eta-proof-store-extraction.md § Finalization Workflowdocs/audits/p0-8-1-merkle-tree-audit.md(template + context for P0.8.1 primitives)docs/contracts/p0-8-1-merkle-tree-contract.mdsrc/domains/proof/merkle.ts(P0.8.1 SHIPPED)src/domains/trail/repository.ts(ζ consumer ordering lesson)CLAUDE.md § 7(writeback ordering: thought_record → merkle_finalize)
6. Open questions resolved in this audit
| Q | A |
|---|---|
Does thought_records already have a session_id column? |
No. This task adds it via migration 005. |
Do we modify src/domains/trail/repository.ts to accept session_id? |
No. The column is nullable and the existing API ignores it. A follow-up task can extend that API. The P0.8.3 handlers use raw SQL on thought_records for the finalize collection. |
Should thought_record inserts force a session_id? |
No — backward compatibility. The audit chain is a new addition that augments the existing chain; it does not replace it. |
What happens after finalization if a new thought_record is inserted against a finalized session? |
AC4 requires “finalization happens AFTER final thought record”. Contract §5 defines “finalize rejects sessions with zero records” AND documents that post-finalization inserts are permitted at the DB layer but SHOULD NOT change the root (the root is captured at finalize time and never re-computed). We test this by attempting a second merkle_finalize on the same session_id — the PK UNIQUE rejects it. Post-finalize thought_record inserts are not rejected at this layer; that policy can be tightened in a later task if needed. |
Does audit_session_start require task_id? |
No — optional. A session may be task-free (e.g., a cross-task review). |