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:
createThoughtRecord(db, input)— pure-factory write-pathgetThoughtRecord(db, id)— single-row readlistThoughtRecords(db, filters)— multi-row readregisterThoughtTools(ctx)— MCP registration wrapper- Types:
CreateThoughtRecordInput,ListThoughtRecordsFilters,ThoughtRecordRow(internal) 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
dbexplicitly. - No module-level env reads. Repository does not read
process.env. The MCP tool handlers readgetDb()from the db singleton — that singleton is set bystartup.tsPhase 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 viacreated_at). Tests inject these via the repository function’s explicitoptionsbag.
§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 viarandomUUID()timestamp— repository mints vianew Date().toISOString()prev_hash— repository resolves from latest-for-task orZERO_HASHhash— repository computes viacomputeHashcreated_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):
- Validate
inputshape:type ∈ THOUGHT_TYPES,task_id.length ≥ 1,agent_id.length ≥ 1,contentis string. Throws on invalid input (Zod-compatible error). - Open a write transaction (
db.transaction(() => {...})). - Lookup:
SELECT hash FROM thought_records WHERE task_id = ? ORDER BY created_at DESC LIMIT 1. - Let
prev_hash= result row’shashif found, elseZERO_HASH. - Let
id=options.idFn?.() ?? randomUUID(). - Let
timestamp=options.nowFn?.() ?? new Date().toISOString(). - Compute
hash = computeHash({id, type, task_id: input.task_id, content, timestamp, prev_hash}). - Let
created_at=timestamp(same value in Phase 0; kept as separate column to allow future divergence). - Insert a row with all 9 columns.
- Return the full
ThoughtRecordshape (8 fields — nocreated_aton the returned object, matchingThoughtRecordtype from P0.7.1).
Invariants:
- Returned
hashis deterministic given{id, type, task_id, content, timestamp, prev_hash}. - Returned
prev_hashis eitherZERO_HASH(first record for this task_id) OR thehashof the immediately-preceding record for the same task_id. idis a fresh UUID-like string on every call (unlessoptions.idFnis 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, omitscreated_atfrom the return shape). - Returns
nullwhen 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_idfilter and optionalLIMIT. - 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:
- Calls
registerColibriTool(ctx, 'thought_record', {inputSchema: ThoughtRecordToolInputSchema, title: 'thought_record', description: '...'}, handler). - Calls
registerColibriTool(ctx, 'thought_record_list', {inputSchema: ThoughtRecordListToolInputSchema, title: 'thought_record_list', description: '...'}, handler). - Both handlers invoke
getDb()at call-time (NOT at registration time) to fetch the process’s DB singleton. thought_recordhandler callscreateThoughtRecord(getDb(), input)and returns the resultingThoughtRecord.thought_record_listhandler callslistThoughtRecords(getDb(), input)and returns{records: ThoughtRecord[]}.
Errors:
- Throws synchronously if
ctxalready has either tool name registered (registerColibriToolenforces). - 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 fullThoughtRecordobject (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_atis ops metadata. hashis UNIQUE — prevents duplicate-record-by-hash inserts (the primary idempotency guarantor).idis 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— thetaskstable 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_hashresolves to the hash of the most recent record for the sametask_id(bycreated_atDESC).- 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_hashrelationship 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’sprev_hashlinks 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 fixedidFn+ fixednowFn+ fixedinput+ fixed pre-state → fixedhash.- This is how the P0.7.1 pinned-hash
6a2f9597...is reproduced: feed{type:'plan', task_id:'t1', agent_id:'a1', content:'hello'}withidFn=() => 'r1'andnowFn=() => '2026-04-17T00:00:00Z'into an empty-chain DB →hash === '6a2f9597f563d5515cfa69891a51806d0f93bfbe222997d3ba37c365ceee3f1a'. - Test §10.7 asserts this.
Non-determinism sources (by design):
- Default
randomUUIDis 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, noupdateThoughtRecord. Corrections are new records withtype: 'reflection'pointing at the prior record’s id via content. - Full-text search — donor FTS5 is not in Phase 0.
- Pagination cursor —
limitis hard-coded at the end;offsetsupport 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).