Audit — R93 B2 audit_verify_chain Cross-Task Partition
Round: R93 debug-sweep (fix #2 of 6)
Branch: feature/r93-b2-verifier-partition
Base SHA: fbc8808a (R92 close)
β task: 635d0ed2-5ee5-4414-93d4-489a07fee1bc
§1. Goal
audit_verify_chain without a task_id filter currently reports the ζ chain as broken even when every per-task chain is individually intact. The MCP handler reads the unfiltered record list and runs the pure verifyChain linearly — but chains are scoped per task_id, so cross-task records always fail the prev_hash linkage check. Fix the handler to partition by task_id before verifying when no filter is supplied. Pure verifyChain is correct and must not change.
§2. Live reproduction
Against the R92 DB (fbc8808a), through the live MCP server:
mcp__colibri__audit_verify_chain {}
→ {valid:false, broken_count:58, record_count:74, first_broken_at:"36482f5a-…"}
mcp__colibri__audit_verify_chain {task_id:"109b31c3-…"}
→ {valid:true, broken_count:0, record_count:4}
74 records across multiple tasks; only 16 of them are first-records on a task chain (which is exactly the 16 valid records the unfiltered call sees correctly), leaving 58 cross-task boundary records that the verifier incorrectly counts as broken.
§3. Code anatomy
§3.1. The chain scope
src/domains/trail/repository.ts:9-13 (canonical docstring):
The chain is SCOPED PER task_id: a record’s prev_hash is the hash of the most-recently-inserted record for the SAME task_id (or ZERO_HASH when the task has no prior record). Cross-task records sit on independent chains — modifying a record in task A never invalidates the chain for task B.
src/domains/trail/repository.ts:254-289 confirms — createThoughtRecord looks up the latest record WHERE task_id = ? to derive prev_hash. Multi-task records are interleaved by rowid in the table.
§3.2. The pure verifier
src/domains/trail/verifier.ts:119-153 — verifyChain(records: ThoughtRecord[]). Walks linearly by array index, checks record.prev_hash === records[i-1].hash for i > 0 and record.prev_hash === ZERO_HASH for i === 0. Returns {valid, broken_count, first_broken_at?}.
The docstring at lines 111-114 acknowledges the limitation:
Multi-task input: when
recordsspans multipletask_idvalues theprev_hashcheck is positional (by array index), not scoped bytask_id. Callers that want per-task isolation should filter before calling. The MCP tool always filters bytask_idwhen that filter is supplied.
The last sentence is the bug: the tool DOES NOT filter when no task_id is supplied. It calls listThoughtRecords(db, {}) and passes the cross-task result straight into verifyChain.
§3.3. The handler
src/domains/trail/verifier.ts:174-202:
(input): { valid, broken_count, first_broken_at?, record_count } => {
const db = getDb();
const filters: ListThoughtRecordsFilters = {};
if (input.task_id !== undefined) {
(filters as Record<string, unknown>)['task_id'] = input.task_id;
}
const records = listThoughtRecords(db, filters);
const result = verifyChain(records);
return { ...result, record_count: records.length };
},
When input.task_id is undefined, filters = {} → listThoughtRecords returns ALL records ordered by rowid → verifyChain walks them as if they were one chain → every task-boundary record fails the linkage check.
§4. Existing test coverage
src/__tests__/domains/trail/verifier.test.ts — 24 tests across Groups A (verifyChain pure-function), B (DB integration via listThoughtRecords + verifyChain), C (registerVerifyChainTool registration + schema).
Notable existing test: line 334-343 “two independent task chains are verified independently” — exercises building two chains and verifying EACH via verifyChain(listThoughtRecords(db, {task_id: 'X'})). This proves the per-task path works; it does not exercise the unfiltered handler path.
The handler itself is registered + schema-validated in Group C but the handler logic (lines 186-200) is never invoked through client.callTool — only registration is asserted. The bug survives 24 tests because the handler-invoke path is missing from coverage.
§5. Constraints on the fix
- MUST NOT modify the pure
verifyChainfunction (it is correct and consumed by other callers). - MUST preserve the existing handler behaviour for
{task_id: "X"}inputs (filtered case). - MUST produce a backward-compatible response shape for the filtered case (
{valid, broken_count, first_broken_at?, record_count}). - MAY extend the unfiltered-case response with an additional
by_taskfield carrying per-partition rollups, so callers can drill in to identify which task’s chain broke. - MUST partition records by
task_idin insertion (rowid) order so the aggregatefirst_broken_atreflects the earliest break across partitions. - MUST hold all the existing 24 verifier tests green.
- MUST add tests that demonstrate the unfiltered path returns
valid: truefor 2+ intact chains andvalid: falsewhen any one partition breaks.
§6. Path forward
Edit only the MCP handler closure in registerVerifyChainTool (src/domains/trail/verifier.ts:174-202). Add a partitioning step when input.task_id is undefined. Extend the typed return to include an optional by_task array. Authors of the pure verifyChain function need no notification — its contract is unchanged.
Proceeding to contract.