Packet: P0.3.3 β Writeback Contract Enforcement
Task: P0.3.3
Branch: feature/p0-3-3-writeback-enforcement
Author: Claude (T3 Executor)
Date: 2026-04-17
Status: APPROVED — gates implementation
1. Files to create
| File | Size estimate |
|---|---|
src/domains/tasks/writeback.ts |
~80 lines |
src/__tests__/domains/tasks/writeback.test.ts |
~200 lines |
2. Files to modify
| File | Change | Lines added |
|---|---|---|
src/domains/tasks/repository.ts |
1 import + 4-line enforcement block | ~5 |
3. src/domains/tasks/writeback.ts — full plan
3.1 Imports
import type Database from 'better-sqlite3';
Only better-sqlite3 type import. No other dependencies (no Zod, no MCP SDK,
no internal imports beyond the type).
3.2 WritebackRequiredError
export class WritebackRequiredError extends Error {
override readonly name = 'WritebackRequiredError';
readonly taskId: string;
readonly missing_fields: string[];
constructor(args: { taskId: string; missing_fields: string[] }) {
super(`Writeback required for task ${args.taskId}: missing ${args.missing_fields.join(', ')}`);
this.taskId = args.taskId;
this.missing_fields = args.missing_fields;
Object.setPrototypeOf(this, WritebackRequiredError.prototype);
}
}
3.3 writebackRequired(db, taskId)
Query: LEFT JOIN tasks + thought_records, GROUP BY task id.
export function writebackRequired(db: Database.Database, taskId: string): boolean {
const row = db.prepare<[string], { status: string; thought_count: number }>(
`SELECT t.status, COUNT(tr.id) AS thought_count
FROM tasks t
LEFT JOIN thought_records tr ON tr.task_id = t.id
WHERE t.id = ?
GROUP BY t.id`
).get(taskId);
if (row === undefined) return false;
return row.status === 'DONE' && row.thought_count === 0;
}
3.4 enforceWriteback(db, taskId)
Point query on thought_records only (no JOIN needed).
export function enforceWriteback(db: Database.Database, taskId: string): void {
const row = db.prepare<[string], { cnt: number }>(
`SELECT COUNT(*) AS cnt FROM thought_records WHERE task_id = ?`
).get(taskId);
const cnt = row?.cnt ?? 0;
if (cnt === 0) {
throw new WritebackRequiredError({
taskId,
missing_fields: ['thought_record'],
});
}
}
4. src/domains/tasks/repository.ts — modification plan
4.1 Import addition (top of file, after existing imports)
import { enforceWriteback } from './writeback.js';
Position: after the existing import { TASK_STATES, type TaskState } from './state-machine.js'; line.
4.2 Hook inside updateTask
Location: after the bindings are built (for loop on PATCHABLE_COLUMNS) and
before const stmt = getUpdateStatement(db, patch). Approximately at line 469.
// P0.3.3: block status→DONE without a ζ thought_record (writeback enforcement).
if ((patch as Record<string, unknown>)['status'] === 'DONE') {
enforceWriteback(db, id);
}
This is 4 lines including the comment. The cast via Record<string, unknown>
avoids TypeScript narrowing complications from UpdateTaskPatch’s optional
status field — the JS runtime check is still type-safe.
Total change to repository.ts: 1 new import line + 4 lines in updateTask = 5 lines.
5. src/__tests__/domains/tasks/writeback.test.ts — test plan
5.1 Setup: both migrations
Both 002_tasks.sql and 003_thought_records.sql must be applied — the
integration tests require both tables to exist.
const TASKS_MIGRATION_SQL = readFileSync(join(here, '..', '..', '..', 'db', 'migrations', '002_tasks.sql'), 'utf-8');
const TRAIL_MIGRATION_SQL = readFileSync(join(here, '..', '..', '..', 'db', 'migrations', '003_thought_records.sql'), 'utf-8');
function makeTestDb(): Database.Database {
const db = new Database(':memory:');
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(TASKS_MIGRATION_SQL);
db.exec(TRAIL_MIGRATION_SQL);
return db;
}
5.2 Helper to insert a thought_record directly
Rather than importing the full createThoughtRecord (which needs hash logic),
use raw SQL INSERT for test isolation. This avoids coupling writeback tests to
trail repository changes.
function insertThoughtRecord(db: Database.Database, taskId: string): void {
db.prepare(
`INSERT INTO thought_records
(id, type, task_id, agent_id, content, timestamp, prev_hash, hash, created_at)
VALUES (?, 'plan', ?, 'test-agent', 'test content', ?, ?, ?, ?)`
).run(randomUUID(), taskId, new Date().toISOString(), '0'.repeat(64), randomUUID(), new Date().toISOString());
}
Note: hash must be UNIQUE — using randomUUID() as a stand-in hash for test
purposes avoids collision. prev_hash uses the zero-hash string for the genesis.
5.3 Test list (12 tests across 5 describe blocks)
describe(‘WritebackRequiredError’)
- T12a:
nameis'WritebackRequiredError' - T12b:
instanceof WritebackRequiredErrorandinstanceof Error - T12c:
taskId,missing_fieldsfields carry provided values - T12d: message format matches
Writeback required for task <id>: missing thought_record
describe(‘writebackRequired’)
- T8: returns
falsefor unknown taskId - T5a: returns
falsefor a task in non-DONE state, no thought_records - T5b: returns
falsefor a task in non-DONE state, with thought_records - T6: returns
truefor DONE task with 0 thought_records - T7: returns
falsefor DONE task with ≥1 thought_records
describe(‘enforceWriteback — direct’)
- T9: returns
voidwhen thought_record present - T10a: throws
WritebackRequiredErrorwhen absent - T10b: error has correct
taskIdandmissing_fields: ['thought_record'] - T11: idempotent — two calls on satisfied task both return
void
describe(‘updateTask → DONE enforcement (integration)’)
- T1:
updateTaskwith{ status: 'DONE' }and no thought_record → throwsWritebackRequiredError - T2:
updateTaskwith{ status: 'DONE' }and 1 thought_record → succeeds, status ='DONE' - T3:
updateTaskwith non-DONE status and no thought_record → succeeds (no enforcement) - T4:
updateTaskwith non-DONE status and thought_record → succeeds
describe(‘task status unchanged on WritebackRequiredError’)
- T1b: after
WritebackRequiredErrorfromupdateTaskto DONE, re-read task still has pre-DONE status
6. Execution sequence
- Write
src/domains/tasks/writeback.ts(new file). - Edit
src/domains/tasks/repository.ts(1 import + 4 lines). - Write
src/__tests__/domains/tasks/writeback.test.ts(new file). - Run
npm test— verify all tests pass. - Run
npm run lint. - Run
npm run build.
7. Risk flags
- Hash uniqueness in tests: raw SQL insert uses
randomUUID()as hash — this is acceptable for test isolation but would not pass chain verification (P0.7.3). Tests must NOT import or depend on chain-verification logic. - Prototype chain:
Object.setPrototypeOfis required (same as existing error classes). Jest’sinstanceofchecks will fail without it in ESM mode. COUNT(tr.id)vsCOUNT(*): in a LEFT JOIN,COUNT(tr.id)correctly counts non-NULL joined rows (0 when no match), whileCOUNT(*)would count 1 even when no thought_record exists (the NULL-padded joined row counts). Must useCOUNT(tr.id)inwritebackRequired.
8. Scope guard
If implementing repository.ts changes requires more than 5 lines, the packet
will be updated and the excess documented in the commit message. Expected: it
will not exceed 5 lines.