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.
updateTaskaccepts any validTaskStateforstatuswithout 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 both002_tasks.sqlAND003_thought_records.sqlapplied (both tables needed for integration tests). beforeEach/afterEachfor db lifecycle.- Import path:
../../../domains/tasks/writeback.js.
6. Error class conventions
Existing error classes in the repo follow:
class Foo extends Errorwithoverride 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:
- Add
import { enforceWriteback } from './writeback.js';at the top. - Inside
updateTask, add oneifblock 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
-
Task-does-not-exist + DONE patch: if caller passes
status: 'DONE'and the task doesn’t exist,enforceWritebackwill find 0 thought_records and throwWritebackRequiredErrorrather thanTaskNotFoundError. This may change caller expectations. Contract must document this ordering explicitly. -
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 NULLclause). Enforcement fires first, then SQL check fires. This is acceptable. -
Cross-task enforcement: enforcement only checks records for the specific
task_id. There is no cross-task risk. -
Concurrency: Phase 0 is single-process. The
enforceWritebackcheck and the subsequentstmt.runare 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
updateTaskinrepository.ts, beforestmt.run. - New module:
src/domains/tasks/writeback.tswithwritebackRequired,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).