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. Thefor…ofloop walks in that order, satisfying I-7.partitionis typed astypeof allRecords(i.e.ThoughtRecordWithSession[]) → structurally assignable toThoughtRecord[]for theverifyChaincall (the WithSession type is a strict superset per repository.ts:109-111).- Optional-property spreads (
...(condition ? {field: val} : {})) honorexactOptionalPropertyTypes: 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.