Contract: 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 (self-approved per Sigma-PM dispatch; no external reviewer gate)
1. Overview
This contract governs the new writeback.ts module and the minimal hook
added to repository.ts. It defines the exact semantics of the three
public exports (WritebackRequiredError, writebackRequired,
enforceWriteback) and the integration contract with the β task transition path.
2. Scope
In scope:
src/domains/tasks/writeback.ts— new module (all three exports)- Minimal hook in
src/domains/tasks/repository.ts:updateTask src/__tests__/domains/tasks/writeback.test.ts— ≥10 tests
Out of scope:
- FSM legality checking (P0.3.4)
- MCP tool registration for writeback
- Cross-task or cross-session chain verification (P0.7.3)
- Any schema changes
3. WritebackRequiredError — error class contract
3.1 Declaration
export class WritebackRequiredError extends Error {
override readonly name = 'WritebackRequiredError';
readonly taskId: string;
readonly missing_fields: string[];
// ...
}
3.2 Invariants
nameis always the string literal'WritebackRequiredError'.taskIdis the task UUID passed toenforceWriteback.missing_fieldsis a non-empty string array. In Phase 0 the only missing field is'thought_record', somissing_fieldsis always['thought_record'].- The instance is both
instanceof WritebackRequiredErrorANDinstanceof Error(prototype chain restored viaObject.setPrototypeOf). messageformat:Writeback required for task <taskId>: missing <fields_csv>where<fields_csv>ismissing_fields.join(', ').
3.3 Constructor
constructor(args: { taskId: string; missing_fields: string[] })
4. writebackRequired(db, taskId) — semantic contract
4.1 Signature
export function writebackRequired(
db: Database.Database,
taskId: string,
): boolean
4.2 Semantics
Returns true when the task exists AND is currently in state 'DONE' AND has
zero thought_records for that taskId — i.e., the task is in a dirty done
state (transitioned to done without writeback).
Returns false in ALL other cases:
- The task is not in
'DONE'state (regardless of thought_record presence). - The task is in
'DONE'state and has ≥ 1thought_recordfor thetaskId. - The
taskIddoes not exist. - The row is soft-deleted (soft-deleted rows have a
statusvalue too; the function checks the raw DB row viataskstable query, not the live-onlygetTaskview).
4.3 Query shape
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
Logic: thought_count === 0 && status === 'DONE' → return true.
4.4 Purpose
writebackRequired is a post-hoc audit predicate — it answers “has this
task already been dirtied to done without a writeback?”. It is NOT the primary
enforcement path (that is enforceWriteback). It exists for monitoring / repair
tooling and is exported as a first-class function for testability.
4.5 Side effects
None. Read-only query.
5. enforceWriteback(db, taskId) — semantic contract
5.1 Signature
export function enforceWriteback(
db: Database.Database,
taskId: string,
): void
5.2 Semantics
Checks whether the task identified by taskId has at least one thought_record
in the ζ trail. If yes, returns void (enforcement satisfied). If no,
throws WritebackRequiredError.
5.3 Query shape
SELECT COUNT(*) AS cnt FROM thought_records WHERE task_id = ?
Logic: cnt === 0 → throw WritebackRequiredError({ taskId, missing_fields: ['thought_record'] }).
5.4 Calling convention
enforceWriteback is called by updateTask ONLY when patch.status === 'DONE',
BEFORE the SQL UPDATE is executed. This means:
- If writeback is absent, the task status is NOT changed. The exception propagates and the caller handles it.
- If writeback is present, execution continues and the
UPDATEfires normally.
5.5 Task existence
enforceWriteback does NOT verify that the task exists. It only checks
thought_records. If the task does not exist, cnt === 0 and
WritebackRequiredError is thrown. This ordering is intentional: the subsequent
stmt.run (SQL update) will return changes === 0 and produce
TaskNotFoundError only if the writeback check is somehow bypassed. In practice,
WritebackRequiredError fires first for non-existent tasks with status 'DONE'
patches — see §8 for rationale.
5.6 Idempotency
Calling enforceWriteback twice on a satisfied task returns void both times —
it is a pure check with no side effects.
5.7 Side effects
None when writeback is satisfied (read-only). Throws when not satisfied.
6. Integration contract — updateTask hook
6.1 Location
Inside src/domains/tasks/repository.ts, function updateTask, BEFORE the
stmt.run(bindings) call.
6.2 Code
// P0.3.3 writeback enforcement: block status→DONE without a thought_record.
if ((patch as Record<string, unknown>)['status'] === 'DONE') {
enforceWriteback(db, id);
}
6.3 Precondition
The check fires only when patch.status === 'DONE'. For all other status values
(or patches with no status field), the check is skipped entirely.
6.4 Import
import { enforceWriteback } from './writeback.js';
Added at the top of repository.ts alongside existing imports.
6.5 No other changes to repository.ts
createTask, getTask, deleteTask, listTasks are not modified. The
enforcement is strictly opt-in per transition target.
7. Test acceptance criteria
All of the following must pass:
| # | Test | Expected outcome |
|---|---|---|
| T1 | updateTask to DONE with no thought_record |
Throws WritebackRequiredError; task status unchanged |
| T2 | updateTask to DONE with ≥1 thought_record |
Succeeds; task status = 'DONE' |
| T3 | updateTask to a non-DONE state with no thought_record |
Succeeds (no enforcement) |
| T4 | updateTask to a non-DONE state with thought_record |
Succeeds |
| T5 | writebackRequired returns false for non-DONE task regardless of thought_records |
false in all non-DONE cases |
| T6 | writebackRequired returns true for DONE task with 0 thought_records |
true |
| T7 | writebackRequired returns false for DONE task with ≥1 thought_records |
false |
| T8 | writebackRequired returns false for unknown taskId |
false |
| T9 | Direct enforceWriteback returns void when thought_record present |
void |
| T10 | Direct enforceWriteback throws WritebackRequiredError when absent |
Throws; taskId and missing_fields correct |
| T11 | enforceWriteback twice on satisfied task returns void both times |
Idempotent |
| T12 | WritebackRequiredError error shape: name, taskId, missing_fields, instanceof |
Correct shape |
8. Error ordering rationale
When a caller does updateTask(db, 'non-existent-id', { status: 'DONE' }):
enforceWritebackfires first → finds 0 thought_records → throwsWritebackRequiredError.TaskNotFoundErroris never reached.
This is acceptable because:
- The primary consumer (P0.3.4 MCP tools) validates task existence before
calling
updateTaskand status via FSM before allowingDONE. - A caller who bypasses those guards and passes a non-existent task ID
deserves some error.
WritebackRequiredErroris conservative (it correctly says “no proof of work”). - The alternative (task-existence check first) would add a second DB query
to
updateTaskfor every call, not justDONEtransitions.
This ordering is documented here and in the verify doc so callers have clear expectations.
9. Non-goals
- FSM legality enforcement (still deferred to P0.3.4 MCP tools).
- Writeback enforcement on
CANCELLEDtransitions. - Writeback enforcement on
createTaskwithstatus: 'DONE'—createTaskis not modified (an impossible edge case in practice; FSM start isINIT). - Multiple
missing_fieldsvalues — Phase 0 only requiresthought_record. - MCP tool registration (P0.3.4 responsibility).
10. Module purity
writeback.ts follows the established pure-module convention:
- Accepts
db: Database.Databaseas an explicit parameter. - No module-level state, no module-level env reads.
- No MCP tool registration.
- No
getDb()calls. - No
console.*output.