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 type
  • listThoughtRecords(db, {task_id, limit}) exists at src/domains/trail/repository.ts:307, but filters by task_id, not session_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:

  1. Create audit_sessions table.
  2. Create merkle_roots table.
  3. Add session_id TEXT NULL column to thought_records.
  4. 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 modify
  • src/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:

  1. audit_session_start returns a valid UUID + persists an audit_sessions row.
  2. merkle_finalize rejects a session with zero thought records (AC4).
  3. merkle_finalize with a pre-set (injected) session_id writes merkle_roots and returns the correct root.
  4. 5-record session: create 5 records with known hashes, call merkle_finalize, compute expected root manually via buildMerkleTree, assert equality. (Acceptance test.)
  5. merkle_finalize is idempotent / rejected-on-replay — second call with the same session_id throws ERR_ALREADY_FINALIZED (PK UNIQUE).
  6. merkle_root returns { root, record_count, finalized_at } for a finalized session.
  7. merkle_root returns ERR_NOT_FINALIZED for a session with no merkle_roots row.
  8. Tool registration: registerMerkleTools registers exactly 3 names; second call throws via the duplicate guard.
  9. Zod schema rejects empty session_id.
  10. thought_record insert with session_id still satisfies the hash-chain invariants (we do NOT include session_id in the hash subset — it remains authorship-adjacent metadata, not chain-integrity data).

5. Canonical references

  • docs/guides/implementation/task-breakdown.md § P0.8.3
  • docs/reference/extractions/eta-proof-store-extraction.md § Finalization Workflow
  • docs/audits/p0-8-1-merkle-tree-audit.md (template + context for P0.8.1 primitives)
  • docs/contracts/p0-8-1-merkle-tree-contract.md
  • src/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).

Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.