P0.3.2 — Step 3 Packet
Execution plan for src/domains/tasks/repository.ts + src/db/migrations/002_tasks.sql + src/db/schema.sql extension + src/__tests__/domains/tasks/repository.test.ts. Gates Step 4 (Implement). Sigma-pre-approved via the dispatch prompt; this packet records the pre-approval and adds packet-level tightening.
Upstream:
- Audit:
docs/audits/p0-3-2-task-crud-audit.md·bb0e914c - Contract:
docs/contracts/p0-3-2-task-crud-contract.md·507f5a6b - Canonical:
docs/3-world/execution/task-pipeline.md·docs/colibri-system.md§6.3
§1. File plan
Four files touched: three new, one edited. No package.json delta (all runtime deps already installed — better-sqlite3 from P0.2.2, crypto is Node built-in).
| Path | Action | LOC estimate | Role |
|---|---|---|---|
src/db/migrations/002_tasks.sql |
new | ~30 | DDL — create tasks + indexes |
src/db/schema.sql |
edit | +~22 | human-readable ownership block |
src/domains/tasks/repository.ts |
new | ~320 | Production module |
src/__tests__/domains/tasks/repository.test.ts |
new | ~650 | Test suite |
Existing directories reused: src/domains/tasks/ (P0.3.1), src/db/migrations/ (P0.2.2), src/__tests__/ (P0.1.2). New directory: src/__tests__/domains/tasks/ (Sigma-specified subfolder).
No README.md in any new directory — module JSDoc and test describe blocks are the documentation.
§2. Design decisions locked in
Carried over from audit + contract. All Sigma-approved at dispatch.
| # | Issue | Decision | Rationale |
|---|---|---|---|
| 1 | Test path | src/__tests__/domains/tasks/repository.test.ts |
Matches Sigma prompt; Jest roots + testMatch accept it. |
| 2 | Migration number | 002 |
Sigma-assigned exclusively; 003 reserved for P0.6.2, 004 for P0.7.2. |
| 3 | Migration filename | 002_tasks.sql (not 002_beta.sql) |
Match the concept table name; runner only parses numeric prefix. |
| 4 | UUID generation | crypto.randomUUID() |
Node built-in, matches src/server.ts correlationId path. |
| 5 | FK on project_id |
None | projects table does not exist in Phase 0. |
| 6 | Priority shape | single TEXT column | Defer two-axis split to later scheduler task; narrow schema now. |
| 7 | FSM enforcement | Not in repository | Dumb CRUD layer; enforcement is tool-layer (P0.3.4). |
| 8 | Singleton db binding | None | Repository accepts db parameter; tests use :memory:. |
| 9 | Soft-delete discipline | getTask / default listTasks hide deleted; updateTask / deleteTask on deleted row throw |
Explicit in contract §3, §5b. |
| 10 | TaskNotFoundError |
Thrown by updateTask / deleteTask only |
getTask / listTasks never throw on empty result. |
| 11 | limit cap |
Clamp to 500 | Defensive; Phase 0 never needs more. |
| 12 | Ordering | created_at DESC, id DESC |
Stable pagination with id tiebreaker. |
§3. Production module skeleton — src/domains/tasks/repository.ts
Ordered top-to-bottom:
§3a. Module header (JSDoc, ~25 lines)
- Purpose line.
- Canonical references (task-pipeline.md, colibri-system.md §6.3, state-machine.ts).
- Chain artifact references (audit, contract, packet).
- Side-effect summary: “pure functions; all I/O is via the caller-provided
dbhandle”. - Consumed-by note: P0.3.3 writeback + P0.3.4 MCP tools.
§3b. Imports
import { randomUUID } from 'node:crypto';
import type { Database } from 'better-sqlite3';
import { TASK_STATES, type TaskState } from './state-machine.js';
type imports per .eslintrc.json consistent-type-imports: error.
§3c. Public types
Task, CreateTaskInput, UpdateTaskPatch, ListTasksFilter — see contract §2.
§3d. TaskNotFoundError
Class as specified in contract §4.
§3e. Constants
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 500;
§3f. Private helpers
rowToTask(row: unknown): Task— casts a raw SQLite row to theTaskinterface. Usesascasts becausenoUncheckedIndexedAccesswould otherwise force per-field nullish checks we cannot meaningfully handle (a row returned bySELECT *either has all columns or the schema is broken, which is a programmer error). The cast is centralized here to keep the rest of the module type-clean.nowIso(): string— thin wrapper aroundnew Date().toISOString(). Kept as a helper for readability; inlining would also be fine.
§3g. Prepared-statement memoization
A module-private WeakMap<Database, { create, get, getForUpdate, update, delete, list, listFiltered, … }> caches compiled statements per db handle. Built lazily on first call for each db. Avoids re-compilation cost on hot paths (the repository is called per MCP request; future load profile benefits from cached statements).
Fallback path: for listTasks with varying filter shapes, statements are built per distinct shape key (${has_status}_${has_project_id}_${include_deleted}_${project_id_is_null}) — at most 2³×2 = 16 distinct shapes, pre-compiled on first use of each shape.
§3h. Public functions
Implementation order of the five exports (§3.1 through §3.5 in the module), each backed by one or more prepared statements:
createTask(db, input)— INSERT then SELECT (one prepared each, bundled in adb.transaction).getTask(db, id)— single SELECT.updateTask(db, id, patch)— dynamic SQL: only fields present in patch go into theSETclause. Because the field set is tiny (6 optional fields) and the type system already enumerates them, we pre-build 64 statements lazily by bitmask of present fields. Each statement has a fixed WHERE clause excluding soft-deleted rows. On.run()with.changes === 0, throwTaskNotFoundError.deleteTask(db, id)— UPDATE SETdeleted_at = ?, updated_at = ?WHEREid = ? AND deleted_at IS NULL. On.changes === 0, throwTaskNotFoundError.listTasks(db, filter)— builds one of the 16 shape-keyed statements; orders bycreated_at DESC, id DESC; applies LIMIT / OFFSET.
§3i. Type helpers consideration
noUncheckedIndexedAccess: true in tsconfig means any row['title'] returns string | undefined. Better-sqlite3 types a .get() result as unknown. The rowToTask helper uses a targeted cast once per read path rather than scattering ?? across the module. This matches P0.2.2 src/db/index.ts which also uses targeted casts for pragma results.
§4. Migration SQL — src/db/migrations/002_tasks.sql
Exact body (non-empty — better-sqlite3.exec('') guard is not hit):
-- 002_tasks — β Task Pipeline CRUD table (P0.3.2).
--
-- Earned by the β concept. The canonical 8 states live in
-- src/domains/tasks/state-machine.ts (TASK_STATES). The CHECK constraint
-- below must stay in sync with that tuple; if TASK_STATES ever changes,
-- a new migration (003_tasks_states.sql or similar) must drop and re-create
-- the CHECK — SQLite does not support ALTER TABLE CHECK modification.
--
-- Soft-delete: deleted_at IS NULL means "live"; a non-null ISO-8601 string
-- means "soft-deleted at that time". The repository layer hides soft-deleted
-- rows from getTask() and from listTasks() default queries.
--
-- project_id is intentionally NOT a FOREIGN KEY — no `projects` table
-- exists in Phase 0. When the projects domain lands (future phase), a
-- separate migration may add the constraint.
--
-- Canonical reference: docs/contracts/p0-3-2-task-crud-contract.md §6.
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
project_id TEXT,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL CHECK (status IN (
'INIT','GATHER','ANALYZE','PLAN','APPLY','VERIFY','DONE','CANCELLED'
)),
priority TEXT,
assignee TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE INDEX idx_tasks_project_status ON tasks(project_id, status, deleted_at);
CREATE INDEX idx_tasks_deleted ON tasks(deleted_at);
No IF NOT EXISTS — the migration runner guarantees one-time application via user_version. Duplicate-apply is a bug in the runner, not something this DDL should mask.
§5. schema.sql additions
After the existing comment header (which ends at line 24), append the tasks ownership block (keeping the file comment-only — it is never executed):
-- ---------------------------------------------------------------------------
-- β Task Pipeline — tasks table (earned by P0.3.2, migration 002_tasks.sql).
-- ---------------------------------------------------------------------------
--
-- CREATE TABLE tasks (
-- id TEXT PRIMARY KEY, -- UUID v4
-- project_id TEXT, -- no FK (no projects table in Phase 0)
-- title TEXT NOT NULL,
-- description TEXT,
-- status TEXT NOT NULL -- one of TASK_STATES (state-machine.ts)
-- CHECK (status IN ('INIT','GATHER','ANALYZE','PLAN','APPLY',
-- 'VERIFY','DONE','CANCELLED')),
-- priority TEXT, -- single-column; may split later
-- assignee TEXT,
-- created_at TEXT NOT NULL, -- ISO-8601
-- updated_at TEXT NOT NULL, -- ISO-8601
-- deleted_at TEXT -- NULL = live; ISO-8601 = soft-deleted
-- );
-- CREATE INDEX idx_tasks_project_status ON tasks(project_id, status, deleted_at);
-- CREATE INDEX idx_tasks_deleted ON tasks(deleted_at);
--
-- Owned by β. Repository module: src/domains/tasks/repository.ts.
-- State-machine enforcement lives in the tool layer (P0.3.4), not here.
§6. Test matrix — src/__tests__/domains/tasks/repository.test.ts
Minimum 30 tests (Sigma spec says ≥25; we go above to cover the listTasks filter combinatorics). All tests use a helper makeTestDb() that returns a fresh :memory: Database with 002_tasks.sql applied:
function makeTestDb(): Database.Database {
const db = new Database(':memory:');
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(readFileSync(MIGRATION_PATH, 'utf-8'));
return db;
}
MIGRATION_PATH resolved once at module scope via fileURLToPath + relative path. No Windows WAL cleanup needed (:memory: has no disk footprint).
§6a. Describe-block structure
describe('createTask')— 6 tests- inserts with minimal input (title only), returns task with UUID id
- default status is ‘INIT’ when not provided
- preserves all optional fields when provided
- created_at equals updated_at at creation
- deleted_at is null at creation
- generates distinct UUIDs for two back-to-back creates
describe('getTask')— 4 tests- returns live task by id
- returns null for unknown id
- returns null for soft-deleted task
- returns all fields correctly after create
describe('updateTask')— 8 tests- applies title-only patch
- applies multi-field patch
- empty patch bumps
updated_atonly - nullable fields can be set to null
updated_atstrictly advances (use monotonic time tick or epsilon check)created_atandidunchanged- throws
TaskNotFoundErrorfor unknown id (withoperation: 'update') - throws
TaskNotFoundErrorfor soft-deleted id (withoperation: 'update')
describe('deleteTask')— 5 tests- soft-deletes a live task (deleted_at becomes non-null)
updated_atbumped on delete- throws
TaskNotFoundErrorfor unknown id (withoperation: 'delete') - throws
TaskNotFoundErrorfor already-deleted id (withoperation: 'delete') getTaskreturns null after delete
describe('listTasks')— 9 tests- returns empty array for empty table
- returns all live tasks (ordered
created_at DESC, id DESC) - filters by status
- filters by project_id (non-null)
- filters by
project_id: null(NULL-valued rows) - excludes soft-deleted by default
- includes soft-deleted when
include_deleted: true - pagination (limit + offset + stable ordering across calls)
- clamps limit over 500 to 500
describe('Task roundtrip')— 3 tests- create → get → update → get → delete → get (full CRUD cycle verifies each step’s state)
- Create 100 tasks, list with pagination (5 pages × 20), verify every unique
- Stress: create + update + delete interleaved across two project_ids, verify listTasks with project_id filter returns correct subset
describe('TaskNotFoundError shape')— 2 tests- carries taskId + operation
- restores prototype chain (
instanceof TaskNotFoundErrorworks)
Total: 37 tests. Comfortably above the 25 floor. Target 100% branch coverage on repository.ts.
§6b. updated_at monotonicity hazard
new Date().toISOString() has millisecond precision. Two successive calls within the same millisecond produce the same string. The “strictly advances” invariant must be asserted with >= old, not > old. Tests use an explicit await new Promise(r => setTimeout(r, 2)) before the second write when strict monotonicity is needed.
§6c. foreign_keys = ON with no FK declared
Safe — PRAGMA only enforces declared FKs; our table has none. Verified empirically in P0.2.2 tests (db-init.test.ts creates no tables with FKs and never trips).
§7. Commit plan
Five commits on feature/p0-3-2-task-crud, in canonical 5-step order:
audit(p0-3-2-task-crud): inventory β state-machine + DB + CRUD heritage— already committed atbb0e914c.contract(p0-3-2-task-crud): behavioral contract— already committed at507f5a6b.packet(p0-3-2-task-crud): execution plan— this commit.feat(p0-3-2-task-crud): β task repository with CRUD + UUID v4 + soft-delete— the four implementation files in a single commit (per P0.2.2 pattern: single feat commit once all four compile-and-pass together).verify(p0-3-2-task-crud): test evidence— verification doc with coverage numbers.
§8. Risk register
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
SQL CHECK constraint drifts from TASK_STATES |
Low | Medium | Single source of truth enforced by a test that reads both TASK_STATES and the migration file’s CHECK clause and asserts they agree. |
noUncheckedIndexedAccess type friction on row access |
Medium | Low | rowToTask helper concentrates casts in one place. |
updated_at test flakiness from ms precision |
Medium | Low | Use >= old checks + explicit 2ms sleep where strict advance is needed. |
Prepared-statement memoization WeakMap bug |
Low | Medium | Covered by every test — any cache miss / wrong SQL surfaces immediately. |
| Dynamic-SQL injection via patch keys | — | High | Impossible by construction — the dynamic SET builder only iterates over a hardcoded whitelist of column names; patch values are bound as parameters. A test specifically asserts the whitelist is exhaustive. |
| Parallel Wave D migration collision (003/004) | — | — | Not our concern; Sigma pre-assigned 002. |
| Cross-worktree file leak (Wave C feedback) | Low | Low | Already verified clean at Step 1. No further parallel waves running against this worktree. |
:memory: DB doesn’t exercise disk-path migration runner |
Low | Low | P0.2.2 already tests the full on-disk migration runner; P0.3.2 tests the SQL correctness only. |
| Sigma baseline vs two-axis priority tension | Low | Low | Keep single TEXT column; log the deferral in contract §9 non-goals. |
No unresolved risks. Safe to proceed to Step 4.
§9. Acceptance criteria map
From the Sigma prompt, each bullet mapped to the code artefact that satisfies it:
| Acceptance bullet | Satisfied by |
|---|---|
createTask(input): inserts, returns with UUID v4 id |
§3h(1) + test createTask > inserts with minimal input, returns task with UUID id |
getTask(id): returns task or null |
§3h(2) + tests in describe('getTask') |
updateTask(id, patch): partial update; returns updated task |
§3h(3) + tests in describe('updateTask') |
deleteTask(id): soft delete (sets deleted_at) |
§3h(4) + tests in describe('deleteTask') |
listTasks({ status?, project_id?, limit?, offset? }): filtered + paginated |
§3h(5) + tests in describe('listTasks') |
| All operations use prepared statements | §3g + implicit across all tests (any raw-SQL path would be caught by type errors or test failures) |
| Test: CRUD roundtrip with all fields | describe('Task roundtrip') §6a |
All seven acceptance bullets have an explicit home.
§10. Exit condition for Step 3
- File plan complete (§1).
- Design decisions locked (§2).
- Production module structure laid out (§3).
- Migration SQL body drafted (§4).
- schema.sql additions drafted (§5).
- Test matrix covers ≥25 tests with target 100% branch coverage (§6).
- Commit plan declared (§7).
- Risks enumerated with mitigations (§8).
- Every Sigma acceptance bullet mapped to an artefact (§9).
Next step: Step 4 Implement. Single feat commit containing all four files once npm test && npm run lint && npm run build all pass.