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-153verifyChain(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 records spans multiple task_id values the prev_hash check is positional (by array index), not scoped by task_id. Callers that want per-task isolation should filter before calling. The MCP tool always filters by task_id when 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 verifyChain function (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_task field carrying per-partition rollups, so callers can drill in to identify which task’s chain broke.
  • MUST partition records by task_id in insertion (rowid) order so the aggregate first_broken_at reflects the earliest break across partitions.
  • MUST hold all the existing 24 verifier tests green.
  • MUST add tests that demonstrate the unfiltered path returns valid: true for 2+ intact chains and valid: false when 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.


Back to top

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

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