Audit: P0.3.3 β Writeback Contract Enforcement

Task: P0.3.3
Branch: feature/p0-3-3-writeback-enforcement
Auditor: Claude (T3 Executor)
Date: 2026-04-17
Phase: Phase 0 — execution axis, β Task Pipeline


1. Purpose

Inventory the existing β and ζ surfaces to identify the precise hook point for writeback enforcement. The goal: a task can only transition to DONE if at least one thought_record row exists for that task_id in the ζ trail.


2. Files read

Path Role
src/domains/tasks/repository.ts β CRUD layer — hook point for enforcement
src/domains/tasks/state-machine.ts β FSM — canonical transition rules
src/domains/trail/repository.ts ζ trail CRUD — thought_records query API
src/db/migrations/002_tasks.sql tasks table schema
src/db/migrations/003_thought_records.sql thought_records table schema
src/__tests__/domains/tasks/repository.test.ts β test structure (mirror)
src/__tests__/domains/trail/repository.test.ts ζ test structure (mirror)

3. β transition surface — repository.ts

3.1 updateTask(db, id, patch) — the single transition point

updateTask is the only function in repository.ts that changes task status. No dedicated transitionTask function exists; the FSM in state-machine.ts is pure-logic (no DB calls). The relevant fact from the repository contract comment (line 19):

“No state-machine enforcement. updateTask accepts any valid TaskState for status without checking legal transitions. FSM policing happens in the MCP tool layer (P0.3.4).”

This is the intended integration point — P0.3.3 adds writeback enforcement inside updateTask for the specific case patch.status === 'DONE'.

3.2 Exact hook location

src/domains/tasks/repository.ts:449 — updateTask(db, id, patch)
  line 461: for (const col of PATCHABLE_COLUMNS) { ... }  ← build bindings
  line 470: const stmt = getUpdateStatement(db, patch);
  line 471: const result = stmt.run(bindings);               ← SQL write
  line 473: if (result.changes === 0) throw TaskNotFoundError

The enforcement call must be placed after building bindings but before stmt.run(bindings) — so the transition is fully blocked (no partial write) when writeback is absent. Specifically: between line 469 (the getUpdateStatement call) and line 471 (stmt.run).

Alternatively, enforcement can precede the entire binding-build section (before line 461) which is cleaner. The earliest safe point is after the id parameter is known and we can query the DB. Chosen location: immediately after line 460 (after id and now are available, before any DB write).

3.3 Error model precedence

When patch.status === 'DONE' and writeback is absent, WritebackRequiredError should be thrown before TaskNotFoundError — the caller hasn’t even gotten to the point of writing yet. If the task doesn’t exist at all, the enforcement query will return zero rows for that task_id, which is the “task not found” path — but enforceWriteback only checks thought_records, not whether the task is alive. The safe contract: check writeback first, then the SQL runs and produces changes === 0 for a missing task.


4. ζ trail surface — thought_records table

Schema (from migration 003_thought_records.sql):

Column Type Notes
id TEXT PK UUID v4
type TEXT ThoughtType literal
task_id TEXT NOT NULL scopes the chain
agent_id TEXT NOT NULL not hashed
content TEXT NOT NULL thought text
timestamp TEXT NOT NULL ISO-8601
prev_hash TEXT NOT NULL 64 hex chars
hash TEXT NOT NULL UNIQUE SHA-256 of 6-field subset
created_at TEXT NOT NULL insertion time

Index idx_trail_task on (task_id, created_at) — used by the existing listThoughtRecords and the chain-tip query in createThoughtRecord.

4.1 Writeback check query

The simplest count query:

SELECT COUNT(*) AS cnt
FROM thought_records
WHERE task_id = ?

Returns cnt > 0 → writeback present. This is a point query on the indexed task_id column — O(log n) at worst.

No JOIN needed. The tasks table need not be consulted for the enforcement check (task existence is verified implicitly by the subsequent stmt.run check).


5. Test structure conventions (Wave A lock)

  • Tests live under src/__tests__/domains/<domain>/.
  • New test file: src/__tests__/domains/tasks/writeback.test.ts.
  • Uses new Database(':memory:') with both 002_tasks.sql AND 003_thought_records.sql applied (both tables needed for integration tests).
  • beforeEach / afterEach for db lifecycle.
  • Import path: ../../../domains/tasks/writeback.js.

6. Error class conventions

Existing error classes in the repo follow:

  • class Foo extends Error with override readonly name = '...'
  • Object.setPrototypeOf(this, Foo.prototype) to restore prototype chain
  • Public readonly fields for structured data
  • Constructor takes an args object { field: type }

WritebackRequiredError will follow this same pattern.


7. Integration with repository.ts — change surface

Minimal diff expected:

  1. Add import { enforceWriteback } from './writeback.js'; at the top.
  2. Inside updateTask, add one if block before the SQL write:
    if ((patch as UpdateTaskPatch).status === 'DONE') {
      enforceWriteback(db, id);
    }
    

Total repository.ts addition: ~3 lines (import + 3-line if block).


8. Residual risks / unknowns

  1. Task-does-not-exist + DONE patch: if caller passes status: 'DONE' and the task doesn’t exist, enforceWriteback will find 0 thought_records and throw WritebackRequiredError rather than TaskNotFoundError. This may change caller expectations. Contract must document this ordering explicitly.

  2. Soft-deleted task + DONE patch: same issue — soft-deleted task will also lack thought_records in the common case, but strictly the thought_records could exist. The spec says “task→done requires thought_record” — soft-deleted tasks can’t go to DONE anyway (updateTask rejects via deleted_at IS NULL clause). Enforcement fires first, then SQL check fires. This is acceptable.

  3. Cross-task enforcement: enforcement only checks records for the specific task_id. There is no cross-task risk.

  4. Concurrency: Phase 0 is single-process. The enforceWriteback check and the subsequent stmt.run are NOT in the same transaction. In theory another writer could delete the thought_record between the check and the write. Phase 0 does not need this guarantee (single MCP client, no concurrent writers). Noted.


9. Conclusion

  • Hook point confirmed: inside updateTask in repository.ts, before stmt.run.
  • New module: src/domains/tasks/writeback.ts with writebackRequired, enforceWriteback, WritebackRequiredError.
  • Integration: 1 import + ~3 lines in updateTask.
  • Tests: src/__tests__/domains/tasks/writeback.test.ts — needs both migrations.
  • No schema changes needed (both tables already exist).

Back to top

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

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