P0.7.2 — Step 2 Behavioral Contract

Binding contract for P0.7.2 ζ Thought Record CRUD — the SQLite-backed repository + 2 MCP tools that persist P0.7.1’s hash-chained thought records. This contract is implementation-binding; deviations require an amended contract.


§1. Module boundary

src/domains/trail/repository.ts owns the ζ persistence surface. The module imports P0.7.1 primitives from ./schema.js and P0.2.2 db from ../../db/index.js. It exports:

  1. createThoughtRecord(db, input) — pure-factory write-path
  2. getThoughtRecord(db, id) — single-row read
  3. listThoughtRecords(db, filters) — multi-row read
  4. registerThoughtTools(ctx) — MCP registration wrapper
  5. Types: CreateThoughtRecordInput, ListThoughtRecordsFilters, ThoughtRecordRow (internal)
  6. ThoughtRecordToolInputSchema, ThoughtRecordListToolInputSchema — Zod schemas for the MCP tools

No other exports. No default export. No side-effects at import.


§2. Purity posture

The module is not pure — it talks to SQLite. But it obeys the following discipline:

  • No module-level state. No singleton db cache, no lazy memoization. Every function takes db explicitly.
  • No module-level env reads. Repository does not read process.env. The MCP tool handlers read getDb() from the db singleton — that singleton is set by startup.ts Phase 2 before any tool can be invoked.
  • No console output. All failures propagate via throw. Logging is the server middleware’s job (stage 5 audit-exit).
  • Deterministic except for 3 explicit seams: randomUUID() (id minting), new Date().toISOString() (timestamp minting), and SQLite row insertion-order (monotonic via created_at). Tests inject these via the repository function’s explicit options bag.

§3. Types

3a. CreateThoughtRecordInput

export interface CreateThoughtRecordInput {
  readonly type: ThoughtType;
  readonly task_id: string;   // non-empty
  readonly agent_id: string;  // non-empty
  readonly content: string;   // empty string allowed
}

Fields NOT in input:

  • id — repository mints via randomUUID()
  • timestamp — repository mints via new Date().toISOString()
  • prev_hash — repository resolves from latest-for-task or ZERO_HASH
  • hash — repository computes via computeHash
  • created_at — repository mints at insert time

3b. ListThoughtRecordsFilters

export interface ListThoughtRecordsFilters {
  readonly task_id?: string;
  readonly limit?: number;  // positive integer; default undefined (no limit)
}

Undefined fields are omitted (matches exactOptionalPropertyTypes: true). limit ≤ 0 is rejected at the Zod layer (positive integer).

3c. CreateOptions (internal test-seam)

export interface CreateOptions {
  readonly idFn?: () => string;            // default: randomUUID
  readonly nowFn?: () => string;           // default: new Date().toISOString()
}

Not documented in the public API; exposed on createThoughtRecord’s signature as an optional 3rd argument. Production callers pass {} or omit.

3d. ThoughtRecordToolInputSchema and ThoughtRecordListToolInputSchema

Re-exported Zod shapes — the MCP tool inputs. See §5a and §5b.


§4. Repository functions

4a. createThoughtRecord

export function createThoughtRecord(
  db: Database.Database,
  input: CreateThoughtRecordInput,
  options?: CreateOptions,
): ThoughtRecord;

Behavior (atomic):

  1. Validate input shape: type ∈ THOUGHT_TYPES, task_id.length ≥ 1, agent_id.length ≥ 1, content is string. Throws on invalid input (Zod-compatible error).
  2. Open a write transaction (db.transaction(() => {...})).
  3. Lookup: SELECT hash FROM thought_records WHERE task_id = ? ORDER BY created_at DESC LIMIT 1.
  4. Let prev_hash = result row’s hash if found, else ZERO_HASH.
  5. Let id = options.idFn?.() ?? randomUUID().
  6. Let timestamp = options.nowFn?.() ?? new Date().toISOString().
  7. Compute hash = computeHash({id, type, task_id: input.task_id, content, timestamp, prev_hash}).
  8. Let created_at = timestamp (same value in Phase 0; kept as separate column to allow future divergence).
  9. Insert a row with all 9 columns.
  10. Return the full ThoughtRecord shape (8 fields — no created_at on the returned object, matching ThoughtRecord type from P0.7.1).

Invariants:

  • Returned hash is deterministic given {id, type, task_id, content, timestamp, prev_hash}.
  • Returned prev_hash is either ZERO_HASH (first record for this task_id) OR the hash of the immediately-preceding record for the same task_id.
  • id is a fresh UUID-like string on every call (unless options.idFn is supplied).
  • The insert is atomic: either all 9 columns are written or none.

Errors:

  • Zod-equivalent validation error on bad input.
  • Error("thought_record: duplicate hash — ...") wrapping SQLite’s UNIQUE constraint violation if two calls produce identical hashes (same 6-field subset, which means they are literally the same record — repeatable caller bug).
  • Propagates any SQLite open/transaction error unchanged.

4b. getThoughtRecord

export function getThoughtRecord(
  db: Database.Database,
  id: string,
): ThoughtRecord | null;

Behavior:

  • SELECT id, type, task_id, agent_id, content, timestamp, prev_hash, hash FROM thought_records WHERE id = ?.
  • Maps row → ThoughtRecord (8 fields, omits created_at from the return shape).
  • Returns null when no row found.

Errors: Propagates SQLite errors unchanged. Does not validate id format.

4c. listThoughtRecords

export function listThoughtRecords(
  db: Database.Database,
  filters?: ListThoughtRecordsFilters,
): ThoughtRecord[];

Behavior:

  • Builds a SELECT with optional task_id filter and optional LIMIT.
  • Orders by created_at ASC (insertion order — matches spec “returns chain in insertion order”).
  • Returns an array of ThoughtRecord (8 fields each).
  • Empty result → empty array (not null).

SQL shapes:

filters Generated SQL
{} or undefined SELECT ... FROM thought_records ORDER BY created_at ASC
{task_id} SELECT ... FROM thought_records WHERE task_id = ? ORDER BY created_at ASC
{task_id, limit} SELECT ... FROM thought_records WHERE task_id = ? ORDER BY created_at ASC LIMIT ?
{limit} SELECT ... FROM thought_records ORDER BY created_at ASC LIMIT ?

Errors: Propagates SQLite errors unchanged.

4d. registerThoughtTools

export function registerThoughtTools(ctx: ColibriServerContext): void;

Behavior:

  1. Calls registerColibriTool(ctx, 'thought_record', {inputSchema: ThoughtRecordToolInputSchema, title: 'thought_record', description: '...'}, handler).
  2. Calls registerColibriTool(ctx, 'thought_record_list', {inputSchema: ThoughtRecordListToolInputSchema, title: 'thought_record_list', description: '...'}, handler).
  3. Both handlers invoke getDb() at call-time (NOT at registration time) to fetch the process’s DB singleton.
  4. thought_record handler calls createThoughtRecord(getDb(), input) and returns the resulting ThoughtRecord.
  5. thought_record_list handler calls listThoughtRecords(getDb(), input) and returns {records: ThoughtRecord[]}.

Errors:

  • Throws synchronously if ctx already has either tool name registered (registerColibriTool enforces).
  • Handler-time errors propagate via the middleware’s HANDLER_ERROR envelope (stage-5 audit-exit logs them).

Registration ordering: tools are registered AT BOOTSTRAP, BEFORE the transport connects. getDb() is invoked at call-time, AFTER Phase 2 has opened the DB. No timing hazard.


§5. MCP tool schemas

5a. thought_record

export const ThoughtRecordToolInputSchema = z.object({
  type: z.enum(THOUGHT_TYPES),
  task_id: z.string().min(1),
  agent_id: z.string().min(1),
  content: z.string(),                 // empty allowed
});
  • Output payload (wrapped in {ok:true, data: ...} by α middleware): the full ThoughtRecord object (8 fields).
  • Error envelope on Zod failure: {ok:false, error: {code: 'INVALID_PARAMS', message, details: {issues}}} (already handled by stage 2 middleware).

5b. thought_record_list

export const ThoughtRecordListToolInputSchema = z.object({
  task_id: z.string().min(1).optional(),
  limit: z.number().int().positive().optional(),
});
  • Output payload: {records: ThoughtRecord[]}.
  • No filter given → returns ALL records (ops inspection use case). For the primary “show me the chain for task X” use case, caller supplies task_id.

§6. Persistence schema

Table thought_records is created by 004_thought_records.sql:

CREATE TABLE thought_records (
  id          TEXT PRIMARY KEY,
  type        TEXT NOT NULL,
  task_id     TEXT NOT NULL,
  agent_id    TEXT NOT NULL,
  content     TEXT NOT NULL,
  timestamp   TEXT NOT NULL,
  prev_hash   TEXT NOT NULL,
  hash        TEXT NOT NULL UNIQUE,
  created_at  TEXT NOT NULL
);
CREATE INDEX idx_trail_task ON thought_records(task_id, created_at);
CREATE INDEX idx_trail_prev ON thought_records(prev_hash);

Schema invariants:

  • 9 columns. 8 are returned to the caller (all except created_at). created_at is ops metadata.
  • hash is UNIQUE — prevents duplicate-record-by-hash inserts (the primary idempotency guarantor).
  • id is PRIMARY KEY — enforces per-row uniqueness (separate from hash).
  • Two indexes: (task_id, created_at) supports both ordered listings and latest-for-task lookups; (prev_hash) supports P0.7.3’s chain verifier.
  • All columns NOT NULL. SQLite’s '' empty-string is not NULL.
  • No CHECK constraints (Zod is source of truth for shape; duplicating invites drift).
  • No foreign key on task_id — the tasks table is owned by P0.3.2 (migration 002), which ships in the same wave; adding an FK here would create a cross-task dependency. Phase 0 explicitly does not enforce task-existence; the chain integrity is semantic (task_id is a label).

Migration ordering: 004 runs after 001 / 002 / 003. Standalone — does not depend on tasks or skills tables.


§7. Chain scope — per-task

(See audit §4 for the full derivation.)

  • prev_hash resolves to the hash of the most recent record for the same task_id (by created_at DESC).
  • If no record exists for that task_id, prev_hash = ZERO_HASH.
  • Across task_ids, chains are independent — record A in task T1 has no prev_hash relationship to record B in task T2.
  • listThoughtRecords({task_id}) returns the chain for that task in insertion order. Caller gets a linear sequence of records where each one’s prev_hash links back to the previous.

Contract assertion: a chain is well-formed iff for every adjacent pair (r_n, r_{n+1}) in the ordered output of listThoughtRecords({task_id}), r_{n+1}.prev_hash === r_n.hash. Tests exercise this property.


§8. Determinism contract

  • createThoughtRecord(db, input, {idFn, nowFn}) with fixed idFn + fixed nowFn + fixed input + fixed pre-state → fixed hash.
  • This is how the P0.7.1 pinned-hash 6a2f9597... is reproduced: feed {type:'plan', task_id:'t1', agent_id:'a1', content:'hello'} with idFn=() => 'r1' and nowFn=() => '2026-04-17T00:00:00Z' into an empty-chain DB → hash === '6a2f9597f563d5515cfa69891a51806d0f93bfbe222997d3ba37c365ceee3f1a'.
  • Test §10.7 asserts this.

Non-determinism sources (by design):

  • Default randomUUID is non-deterministic. Tests override.
  • Default new Date().toISOString() is non-deterministic. Tests override.

§9. Error handling

9a. Input validation

Repository functions validate minimally (same Zod schema as the MCP tool). Invalid input throws ZodError. The MCP tool’s stage-2 middleware catches this and converts to INVALID_PARAMS.

Rationale: belt-and-suspenders. The MCP tool validates BEFORE the handler; the handler validates again for direct (non-MCP) callers. Both use the same schema — no drift risk.

9b. UNIQUE hash collision

If a caller submits the same 6-field subset twice (same task_id with no intervening record, same type + content + agent_id with mocked idFn+nowFn seams), the second insert fails with SQLite UNIQUE constraint violation. Repository re-throws unchanged; the middleware envelope-wraps.

Production callers using default idFn + nowFn cannot hit this — randomUUID() is collision-free in any reasonable time frame.

9c. Missing task_id on create

Zod rejects. INVALID_PARAMS envelope.

9d. Empty DB on list

Not an error — returns [].

9e. getThoughtRecord(db, nonexistent_id)

Returns null, not a throw.


§10. Test matrix (binding for Step 3)

Target: ≥25 tests, 100% branch coverage on repository.ts. Minimum behaviours:

# Behavior Assertion
1 createThoughtRecord genesis record → prev_hash === ZERO_HASH insert into empty table, inspect returned record
2 createThoughtRecord second record → prev_hash === first.hash insert two records for same task, verify link
3 createThoughtRecord different tasks have independent chains insert A into task1, B into task2 → both have prev_hash === ZERO_HASH
4 createThoughtRecord auto-mints id as UUID v4 shape returned id matches /^[0-9a-f]{8}-...-[0-9a-f]{12}$/ (or, with injected idFn, equals injected value)
5 createThoughtRecord auto-mints timestamp as ISO-8601 returned timestamp matches ISO-8601 regex; or matches injected value
6 createThoughtRecord preserves type (all 4 values) 4 sub-cases, each ThoughtType value survives round-trip
7 createThoughtRecord preserves agent_id (not in hash, but stored + returned) agent_id in → same agent_id out
8 createThoughtRecord empty-string content works content: '' → insert succeeds, returned
9 createThoughtRecord determinism — pinned hash inject idFn: ()=>'r1', nowFn: ()=>'2026-04-17T00:00:00Z', assert hash === '6a2f9597f563d5515cfa69891a51806d0f93bfbe222997d3ba37c365ceee3f1a'
10 createThoughtRecord computed hash matches re-computed schema.computeHash compute against returned record’s 6 subset fields, assert equal
11 createThoughtRecord Zod rejects missing type call with type: undefined → throws
12 createThoughtRecord Zod rejects empty task_id task_id: '' → throws
13 createThoughtRecord Zod rejects empty agent_id agent_id: '' → throws
14 createThoughtRecord Zod rejects bad type value type: 'observation' → throws
15 getThoughtRecord returns null for missing id getThoughtRecord(db, 'no-such-id')null
16 getThoughtRecord returns full record including hash insert + fetch, all 8 fields match
17 getThoughtRecord across tasks record A in task1, B in task2 → getThoughtRecord(db, a.id) returns A correctly
18 listThoughtRecords empty DB → empty array no inserts → []
19 listThoughtRecords no filters returns all insert 3 records, no filter → 3 records
20 listThoughtRecords task_id filter insert 2 for task1 + 1 for task2 → filter task1 → 2 records
21 listThoughtRecords insertion order (ASC) insert 3 records, verify order matches insertion
22 listThoughtRecords limit clamps result insert 5, limit=2 → 2 records
23 listThoughtRecords limit + task_id insert 3 task1 + 3 task2, filter task1 + limit 2 → 2 task1 records
24 listThoughtRecords preserves chain linkage insert chain of 3, list, assert each record.prev_hash === previous.hash
25 thought_record MCP tool envelope (ok:true) callTool → structuredContent.data matches ThoughtRecord shape
26 thought_record MCP tool INVALID_PARAMS on bad input callTool with type: 'bad' → isError, INVALID_PARAMS code
27 thought_record_list MCP tool envelope callTool → structuredContent.data.records is array
28 thought_record_list MCP tool filter by task_id insert cross-task, callTool with task_id → filtered
29 registerThoughtTools registers both names ctx._registeredToolNames has ‘thought_record’ + ‘thought_record_list’
30 registerThoughtTools idempotent-failure on double-register calling twice throws (via registerColibriTool)

Total: 30 tests. Packet §3 commits the exact test names.


§11. Non-goals

Explicitly out of scope for P0.7.2:

  • Chain verification — P0.7.3 owns audit_verify_chain. Repository does NOT re-compute hashes at list time; it trusts stored hashes.
  • Session-scoped chains — donor had session_id; Phase 0 does not. P0.7.x+ may re-introduce.
  • Branching — strict linear chain per task_id. No parent_id column.
  • Per-skill verification — unrelated; ε is P0.6.
  • Merkle proofs — P0.8 η.
  • Deletion / update — thought records are append-only. No deleteThoughtRecord, no updateThoughtRecord. Corrections are new records with type: 'reflection' pointing at the prior record’s id via content.
  • Full-text search — donor FTS5 is not in Phase 0.
  • Pagination cursorlimit is hard-coded at the end; offset support deferred to later phases.

§12. Contract acceptance

  • Module boundary + 6 exports (§1).
  • Purity posture + 3 explicit non-determinism seams (§2).
  • 4 public types (§3).
  • 4 repository functions with signatures + behavior + errors (§4).
  • 2 MCP tool input schemas + output envelopes (§5).
  • SQL schema + 2 indexes (§6).
  • Per-task chain scope confirmed (§7).
  • Determinism contract + pinned-hash assertion (§8).
  • Error handling taxonomy (§9).
  • 30-test matrix (§10).
  • Non-goals fenced (§11).

Ready to proceed to Step 3 (packet).


Back to top

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

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