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: name is 'WritebackRequiredError'
  • T12b: instanceof WritebackRequiredError and instanceof Error
  • T12c: taskId, missing_fields fields carry provided values
  • T12d: message format matches Writeback required for task <id>: missing thought_record

describe(‘writebackRequired’)

  • T8: returns false for unknown taskId
  • T5a: returns false for a task in non-DONE state, no thought_records
  • T5b: returns false for a task in non-DONE state, with thought_records
  • T6: returns true for DONE task with 0 thought_records
  • T7: returns false for DONE task with ≥1 thought_records

describe(‘enforceWriteback — direct’)

  • T9: returns void when thought_record present
  • T10a: throws WritebackRequiredError when absent
  • T10b: error has correct taskId and missing_fields: ['thought_record']
  • T11: idempotent — two calls on satisfied task both return void

describe(‘updateTask → DONE enforcement (integration)’)

  • T1: updateTask with { status: 'DONE' } and no thought_record → throws WritebackRequiredError
  • T2: updateTask with { status: 'DONE' } and 1 thought_record → succeeds, status = 'DONE'
  • T3: updateTask with non-DONE status and no thought_record → succeeds (no enforcement)
  • T4: updateTask with non-DONE status and thought_record → succeeds

describe(‘task status unchanged on WritebackRequiredError’)

  • T1b: after WritebackRequiredError from updateTask to DONE, re-read task still has pre-DONE status

6. Execution sequence

  1. Write src/domains/tasks/writeback.ts (new file).
  2. Edit src/domains/tasks/repository.ts (1 import + 4 lines).
  3. Write src/__tests__/domains/tasks/writeback.test.ts (new file).
  4. Run npm test — verify all tests pass.
  5. Run npm run lint.
  6. 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.setPrototypeOf is required (same as existing error classes). Jest’s instanceof checks will fail without it in ESM mode.
  • COUNT(tr.id) vs COUNT(*): in a LEFT JOIN, COUNT(tr.id) correctly counts non-NULL joined rows (0 when no match), while COUNT(*) would count 1 even when no thought_record exists (the NULL-padded joined row counts). Must use COUNT(tr.id) in writebackRequired.

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.


Back to top

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

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