Packet — R93 B2 audit_verify_chain Cross-Task Partition

Round: R93 debug-sweep Branch: feature/r93-b2-verifier-partition Audit: docs/audits/r93-b2-verifier-partition-audit.md Contract: docs/contracts/r93-b2-verifier-partition-contract.md β task: 635d0ed2-5ee5-4414-93d4-489a07fee1bc

§1. Edit list

# File Operation Range / Anchor
1 src/domains/trail/verifier.ts Edit Handler closure inside registerVerifyChainTool (lines 174-202). Replace the linear-walk handler with a branched implementation: filtered path unchanged; unfiltered path partitions by task_id and aggregates. Widen the typed return to include optional by_task.
2 src/__tests__/domains/trail/verifier.test.ts Edit Append a new describe group at the end of the file (Group D — “audit_verify_chain MCP handler — cross-task partitioning”). Tests build chains via createThoughtRecord then invoke the handler closure through the registered ctx (or via the same code path the handler runs).

No source files outside src/domains/trail/ are touched. No new dependencies. No migrations.

§2. Handler patch — src/domains/trail/verifier.ts:174-202

The current single-arm handler becomes a two-arm handler:

(input): {
  valid: boolean;
  broken_count: number;
  first_broken_at?: string;
  record_count: number;
  by_task?: ReadonlyArray<{
    task_id: string;
    valid: boolean;
    broken_count: number;
    record_count: number;
    first_broken_at?: string;
  }>;
} => {
  const db = getDb();

  // Filtered path: unchanged. Single task_id → single chain → bare result.
  if (input.task_id !== undefined) {
    const records = listThoughtRecords(db, { task_id: input.task_id });
    const result = verifyChain(records);
    return { ...result, record_count: records.length };
  }

  // R93 B2: Unfiltered path partitions by task_id before verifying.
  // Chains are scoped per task_id (repository.ts:9-13); walking the
  // cross-task linear list with verifyChain would flag every task
  // boundary as broken. Partition first, run verifyChain per partition,
  // aggregate. See docs/audits/r93-b2-verifier-partition-audit.md.
  const allRecords = listThoughtRecords(db);
  const groups = new Map<string, typeof allRecords>();
  for (const r of allRecords) {
    const existing = groups.get(r.task_id);
    if (existing === undefined) {
      groups.set(r.task_id, [r]);
    } else {
      existing.push(r);
    }
  }

  const byTask: Array<{
    task_id: string;
    valid: boolean;
    broken_count: number;
    record_count: number;
    first_broken_at?: string;
  }> = [];
  let aggregateBroken = 0;
  let aggregateFirstBrokenAt: string | undefined;

  for (const [taskId, partition] of groups) {
    const partitionResult = verifyChain(partition);
    const taskRow: {
      task_id: string;
      valid: boolean;
      broken_count: number;
      record_count: number;
      first_broken_at?: string;
    } = {
      task_id: taskId,
      valid: partitionResult.valid,
      broken_count: partitionResult.broken_count,
      record_count: partition.length,
      ...(partitionResult.first_broken_at !== undefined
        ? { first_broken_at: partitionResult.first_broken_at }
        : {}),
    };
    byTask.push(taskRow);
    aggregateBroken += partitionResult.broken_count;
    if (
      aggregateFirstBrokenAt === undefined &&
      partitionResult.first_broken_at !== undefined
    ) {
      aggregateFirstBrokenAt = partitionResult.first_broken_at;
    }
  }

  return {
    valid: aggregateBroken === 0,
    broken_count: aggregateBroken,
    record_count: allRecords.length,
    ...(aggregateFirstBrokenAt !== undefined
      ? { first_broken_at: aggregateFirstBrokenAt }
      : {}),
    by_task: byTask,
  };
},

Notes:

  • Map<string, T[]> preserves insertion order in JS — groups.set(taskId, …) on first appearance gives us first-appearance ordering by rowid. The for…of loop walks in that order, satisfying I-7.
  • partition is typed as typeof allRecords (i.e. ThoughtRecordWithSession[]) → structurally assignable to ThoughtRecord[] for the verifyChain call (the WithSession type is a strict superset per repository.ts:109-111).
  • Optional-property spreads (...(condition ? {field: val} : {})) honor exactOptionalPropertyTypes: true — the codebase already uses this pattern in router/tools.ts:302-309 and consensus/tools.ts:472-485.

§3. New tests — src/__tests__/domains/trail/verifier.test.ts

Append at end of file. The new tests invoke the handler indirectly via the same partitioning logic the production handler runs — or, where possible, through registerVerifyChainTool + client.callTool with InMemoryTransport.

Looking at the existing Group C registration tests (lines 359-381) they don’t invoke through a client; they only assert ctx._registeredToolNames.has(...). For Group D, the cleanest approach is to call the handler logic directly. We can do that by extracting the closure into a helper, OR by exporting a helper from the source. Cleanest is to add an exported helper next to verifyChain that callers can drive with (db, task_id?) — but that’s a public-API change.

Selected approach: test through the registered handler by registering audit_verify_chain on a real ctx with InMemoryTransport + a paired Client, then calling client.callTool({name:'audit_verify_chain', arguments:{}}). This mirrors B1’s regression pattern and is the most realistic. Requires makeLinkedPair analogue in verifier.test.ts.

Test draft (5 cases, may add more during implement):

describe('audit_verify_chain — cross-task partitioning (R93 B2)', () => {
  // helper to spin up a ctx + client with the trail tool registered + a
  // dependency-injected getDb that returns our test db
  async function makeVerifierLinkedPair(testDb: Database.Database): Promise<{
    client: Client;
    cleanup: () => Promise<void>;
  }> {
    // …reuses the makeLinkedPair pattern from server.test.ts, registers
    // audit_verify_chain via a thin shim that closes over testDb instead
    // of getDb(). See implement step for exact wiring.
  }

  it('two intact chains return aggregate valid:true with two by_task rows', async () => {  });
  it('one intact + one tampered → aggregate valid:false; broken_count = sum', async () => {  });
  it('by_task ordering matches first-appearance (rowid) order of task_ids', async () => {  });
  it('empty DB → valid:true, record_count:0, by_task:[]', async () => {  });
  it('single-task DB with no filter → valid:true, by_task has one row', async () => {  });
  it('filtered path (task_id supplied) returns no by_task field', async () => {  });
});

Note: getting a real client to use a test DB requires either (a) a fixture that runs the migrations against the production getDb() singleton in test, or (b) exporting a smaller helper. Option (a) is invasive (mutates global state) and (b) is API surface change.

Simpler path: since the entire fix is in the handler closure, we can copy the partitioning logic into the test by re-implementing the same partition-then-verify flow against the test DB. This is essentially an integration test of the algorithm, not of the registered tool. It’s not as ideal as a true wire test, but it gives us coverage of the partition logic. Combined with at least one wire-level test (registering the tool with makeLinkedPair and client.callTool against a known-built test DB via a brief getDb-override seam) we cover both layers.

The implement step will choose between these strategies based on what minimises diff risk.

§4. Build/lint/test gates

npm run build && npm run lint && npm test. Suite count unchanged. Test count increases by the number of new assertions in the new describe block (estimated +5 to +8).

§5. Commit message template

fix(r93-b2): partition audit_verify_chain by task_id when no filter

audit_verify_chain without a task_id filter was walking the cross-task
record list through the pure verifyChain — which checks prev_hash
linkage positionally, not per-chain. ζ chains are scoped per task_id
(repository.ts:9-13), so every task boundary was flagged as broken,
producing false-positive {valid:false, broken_count:58, record_count:74}
reports on the live DB.

Fix: in the MCP handler closure only (verifier.ts:174-202), branch on
input.task_id. Filtered path unchanged. Unfiltered path partitions
records by task_id (in first-appearance rowid order via Map insertion),
runs verifyChain per partition, and aggregates {valid, broken_count,
record_count, first_broken_at?}. The unfiltered response also carries
a new by_task[] array carrying the per-partition rollup so callers
can drill in to find the broken task.

The pure verifyChain function is unchanged. All 24 existing tests
in verifier.test.ts pass byte-identically. New Group D tests exercise
the unfiltered handler path that the existing suite never invoked.

Closes R93 B2.

Live audit: docs/audits/r93-b2-verifier-partition-audit.md

Proceeding to implement.


Back to top

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

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