P0.3.1 — Step 2 Contract
Behavioral contract for src/domains/tasks/state-machine.ts — the canonical β Task Pipeline finite-state machine (FSM) for Colibri Phase 0. Pure-logic module, no I/O, no persistence, no events. Consumed later by P0.3.2 repository.ts and P0.3.4 task_update / task_next_actions MCP tools.
Canonical source of the FSM: docs/colibri-system.md §6.3, with transition rules in docs/3-world/execution/task-pipeline.md and the 5-step-chain mapping in docs/5-time/task.md.
Audit artefact: docs/audits/p0-3-1-task-state-machine-audit.md (commit 06d36a78).
§1. Module surface
src/domains/tasks/state-machine.ts exports exactly the following symbols (no more, no less):
| Export | Kind | Shape |
|---|---|---|
TASK_STATES |
const (tuple) |
readonly ['INIT', 'GATHER', 'ANALYZE', 'PLAN', 'APPLY', 'VERIFY', 'DONE', 'CANCELLED'] |
TaskState |
type alias |
(typeof TASK_STATES)[number] — union of the 8 literals |
TERMINAL_STATES |
const (frozen set) |
ReadonlySet<TaskState> containing {'DONE', 'CANCELLED'} |
VALID_TRANSITIONS |
const (frozen record) |
Readonly<Record<TaskState, readonly TaskState[]>> |
InvalidTransitionError |
class (extends Error) |
Carries { from: TaskState; to: TaskState; taskId: string } |
canTransition |
function |
(from: TaskState, to: TaskState) => boolean |
transition |
function (generic) |
<T extends TaskShape>(task: T, to: TaskState) => T |
TaskShape |
interface |
Minimal input shape for transition — { id: string; state: TaskState } |
No other exports. No default export. No re-exports. No mutable state. No top-level side effects beyond module-scoped frozen literals.
§2. The 8 states
Exactly eight TaskState literals, in canonical order, per docs/colibri-system.md §6.3:
INIT → GATHER → ANALYZE → PLAN → APPLY → VERIFY → DONE
↘ CANCELLED
Semantics (from docs/3-world/execution/task-pipeline.md lines 24–33):
| State | Meaning | Terminal? |
|---|---|---|
INIT |
Task accepted, not yet started | No |
GATHER |
Context collected (5-step Step 1: Audit) | No |
ANALYZE |
Problem framed (5-step Step 2: Contract) | No |
PLAN |
Packet written (5-step Step 3: Packet) | No |
APPLY |
Packet executed (5-step Step 4: Implement) | No |
VERIFY |
Acceptance criteria checked (5-step Step 5: Verify) | No |
DONE |
Writeback complete | Yes |
CANCELLED |
Explicit abandon with reason | Yes |
These map 1:1 to the 5-step executor chain as documented in docs/5-time/task.md §”The β state machine”; INIT and DONE bookend the chain, CANCELLED is the one off-ramp.
§3. Transition map (canonical)
Exact adjacency derived from the three canonical sources (audit §2a):
| From | Allowed to targets |
|---|---|
INIT |
GATHER, CANCELLED |
GATHER |
ANALYZE, CANCELLED |
ANALYZE |
PLAN, CANCELLED |
PLAN |
APPLY, CANCELLED |
APPLY |
VERIFY, CANCELLED |
VERIFY |
DONE, GATHER, CANCELLED |
DONE |
(empty — terminal) |
CANCELLED |
(empty — terminal) |
Total edges: 13 = 6 forward (INIT→GATHER, GATHER→ANALYZE, ANALYZE→PLAN, PLAN→APPLY, APPLY→VERIFY, VERIFY→DONE) + 1 retry (VERIFY→GATHER) + 6 cancel (INIT/GATHER/ANALYZE/PLAN/APPLY/VERIFY → CANCELLED).
Rules embedded in the map:
- No skipping. ANALYZE has no edge to APPLY; the packet must exist. (Audit §2a · task-pipeline.md lines 35–37.)
- Verification is mandatory. APPLY → VERIFY is the only forward edge from APPLY; the packet cannot skip straight to DONE. (task-pipeline.md lines 37–38.)
- Retry is modelled by a backward edge, not a new state. Transient VERIFY failure re-enters GATHER via VERIFY → GATHER. Attempt-counter bookkeeping is the caller’s responsibility (outside this module’s contract). (task-pipeline.md lines 40 + 22.)
- Permanent VERIFY failure is modelled by VERIFY → CANCELLED, not a new state. Reason-string bookkeeping (e.g.
verify_permanent_fail) is the caller’s responsibility. (task-pipeline.md line 40.) - Cancel is reachable from every non-terminal state, including INIT (cancel-before-start is valid). Six cancel edges total — one per non-terminal state.
- Terminal states have zero outgoing edges. Any
transition(task, *)call wheretask.state ∈ {'DONE', 'CANCELLED'}throwsInvalidTransitionError, regardless of the target. - Self-loops are invalid. No state transitions to itself. This includes
DONE → DONE(rejected because DONE is terminal; any outgoing edge from DONE throws) andVERIFY → VERIFY(rejected because it is not in the map).
Out-of-scope transitions (documented to forestall future ambiguity):
- No
GATHER → GATHER(retries re-enter GATHER from VERIFY, not from GATHER itself; GATHER does not self-loop). - No
INIT → *exceptGATHERandCANCELLED. A “reset to INIT” is not expressible in this FSM; the caller must create a new task row. - No
CANCELLED → *. A cancelled task cannot be un-cancelled; the caller must create a new task row.
§4. Function contracts
§4.1 canTransition(from, to) → boolean
export function canTransition(from: TaskState, to: TaskState): boolean;
Purity contract: pure. No side effects. Never throws. Never reads process.*, never logs, never allocates beyond the single boolean return.
Semantics:
- Returns
trueiff the ordered pair(from, to)is listed in the transition map (§3). - Returns
falsefor every other ordered pair, including:- Self-loops (e.g.
canTransition('INIT', 'INIT')→false). - Any edge out of a terminal state (e.g.
canTransition('DONE', 'GATHER')→false;canTransition('CANCELLED', 'INIT')→false). - Skipped edges (e.g.
canTransition('ANALYZE', 'APPLY')→false). - Reversed non-retry edges (e.g.
canTransition('GATHER', 'INIT')→false).
- Self-loops (e.g.
Domain: both arguments are TaskState (compile-time guaranteed by the union). Callers may receive strings from untrusted input (e.g. SQLite query results); they are responsible for guarding the cast into TaskState. This module does not re-validate.
Total / pure function — 8 × 8 = 64 ordered pairs; 13 map to true, 51 map to false.
§4.2 transition(task, to) → T
export function transition<T extends TaskShape>(task: T, to: TaskState): T;
where
export interface TaskShape {
readonly id: string;
readonly state: TaskState;
}
Purity contract: pure modulo thrown exceptions. No side effects. No process.*, no logging, no persistence.
Semantics:
- If
canTransition(task.state, to)istrue, returns{ ...task, state: to }— i.e. a new object with the updated state field, with all other fields shallow-copied fromtask. The inputtaskis NOT mutated. - If
canTransition(task.state, to)isfalse, throwsInvalidTransitionErrorwith{ from: task.state, to, taskId: task.id }.
Shallow-copy caveat: the generic T preserves all fields of the caller’s task type (e.g. a future TaskRow with timestamps, writer, metadata). Spread is shallow — nested objects (e.g. a hypothetical metadata: { … }) are reference-shared with the input. If the caller needs deep-freeze or structural isolation, that is the caller’s responsibility. The state machine only owns the state field.
Immutability of input: the returned object is NOT === to the input when the transition succeeds. The input is not touched. Assertion (task as unknown as { state: TaskState }).state remains the original value after a successful call.
§4.3 InvalidTransitionError
export class InvalidTransitionError extends Error {
readonly name: 'InvalidTransitionError';
readonly from: TaskState;
readonly to: TaskState;
readonly taskId: string;
constructor(args: { from: TaskState; to: TaskState; taskId: string });
}
Instances:
nameis the literal string'InvalidTransitionError'(set in the constructor, not inherited —Error.namedefaults to'Error'otherwise under subclassing in ts-jest ESM).from,to,taskIdarereadonlyinstance fields mirroring the constructor arg.messageformat:`Invalid task transition for task ${taskId}: ${from} → ${to}`. The format is deterministic and stable — tests match on substring (/Invalid task transition/,/→/, and the three values).instanceof Errorholds.instanceof InvalidTransitionErrorholds.- Standard
Error.prototype.stackis preserved (no custom stack manipulation).
Error-field shape matches docs/spec/s17-errors.md — the standard Colibri error envelope carries { code, message, data }. InvalidTransitionError is a plain Error subclass with structured public fields; wrapping into the s17 envelope is the MCP handler’s job (P0.3.4 territory), not this module’s.
§5. Typing discipline
as conston the tuple.TASK_STATESis declared withas const, yieldingreadonly [literal, literal, …]. This makesTaskState = (typeof TASK_STATES)[number]a union of literal strings.satisfiesfor static self-checks. A module-scopedconst _exhaustive: readonly TaskState[] = TASK_STATES satisfies readonly TaskState[];compile-time check (unused at runtime) guards against accidental mutation of the tuple to the wrong width.Record<TaskState, …>forVALID_TRANSITIONS. The record type is exhaustive — dropping a key is a compile-time error. UndernoUncheckedIndexedAccess,VALID_TRANSITIONS[from]is typed asreadonly TaskState[] | undefined; the implementation uses the nullish-coalescing pattern(VALID_TRANSITIONS[from] ?? []).includes(to)so the TypeScript compiler is satisfied even though, at runtime, the record is exhaustive. No!non-null assertions.Object.freezeon all module-scoped singletons.TASK_STATESis already readonly fromas const; the record and set areObject.freezed.ReadonlySet<TaskState>is the exposed type forTERMINAL_STATES.- Generic over
TaskShape.transition<T extends TaskShape>(task: T, to: TaskState): Tpreserves the caller’s type. Noany, nounknown, no casts. - Named constructor arg object.
InvalidTransitionError’s constructor takes a single object argument{ from, to, taskId }. This keeps call sites self-documenting and matches the future s17 error envelope ({ code, message, data: { from, to, taskId } }).
§6. Test contract (for the test file)
The test file src/__tests__/task-state-machine.test.ts MUST exercise:
§6.1 State enumeration
TASK_STATEShas length exactly 8.new Set(TASK_STATES)equalsnew Set(['INIT', 'GATHER', 'ANALYZE', 'PLAN', 'APPLY', 'VERIFY', 'DONE', 'CANCELLED']).TERMINAL_STATEShas size 2 and equalsnew Set(['DONE', 'CANCELLED']).
§6.2 canTransition — valid edges
13 explicit expect(canTransition(a, b)).toBe(true) assertions, one per edge in §3.
§6.3 canTransition — invalid edges
51 invalid ordered pairs. Exhaustive coverage is feasible; the test groups them:
- Self-loops (8):
(X, X)for eachTaskState. - Out-of-terminal (14):
(DONE, *)for 7 targets +(CANCELLED, *)for 7 targets (all 14tovalues include both terminals, so(DONE, DONE)overlaps with the self-loop — deduplicated to 14 unique).- Actually:
(DONE, *)is 7 pairs (excludingDONE → DONEwhich is already in self-loops) plus(DONE, DONE)self-loop = covered once. - Similarly
(CANCELLED, *). - The test deduplicates via a
Set<"${from}->${to}">to avoid double-counting.
- Actually:
- Skipped forward edges (e.g.
INIT → ANALYZE,GATHER → PLAN,ANALYZE → APPLY,PLAN → VERIFY,APPLY → DONE). - Reversed non-retry edges (e.g.
GATHER → INIT,ANALYZE → GATHER,PLAN → ANALYZE,APPLY → PLAN). - Remaining combinations by iteration over all 64 ordered pairs, with the 13 valid ones subtracted.
The test asserts: for every ordered pair (from, to) in the 64-pair product, canTransition(from, to) equals VALID_TRANSITIONS[from].includes(to). This single loop covers all 64 pairs at once and is the primary branch-coverage driver.
§6.4 transition — happy path
- For every edge in §3, construct a task
{ id: 't1', state: from }, calltransition(task, to), assert:- Return value is a new object (
!==original). - Return value has
state === to. - Return value has
id === 't1'. - Input
task.stateis unchanged.
- Return value is a new object (
- For a caller task with extra fields (e.g.
{ id, state, foo: 'bar' }), assert the extra field is preserved on the return value.
§6.5 transition — throws
- For every invalid ordered pair, assert
transition({ id: 'tX', state: from }, to)throwsInvalidTransitionError. - Assert the thrown error has
from === from,to === to,taskId === 'tX'. - Assert the message includes the taskId, the from-state, the to-state, and the
'→'arrow. - Assert
err instanceof Error. - Assert
err instanceof InvalidTransitionError. - Assert
err.name === 'InvalidTransitionError'.
§6.6 Terminal exits
- DONE exit — iterate over all 8 possible
tovalues and assert each throws whenfrom === 'DONE'. - CANCELLED exit — iterate over all 8 possible
tovalues and assert each throws whenfrom === 'CANCELLED'.
§6.7 Branch coverage
Expected: 100% stmt / 100% branch / 100% func / 100% line on src/domains/tasks/state-machine.ts, per jest.config.ts collectCoverage: true.
The coverage-producing branches are:
- The
.includes()truthy branch incanTransition. - The
.includes()falsy branch incanTransition. - The
VALID_TRANSITIONS[from] ?? []nullish-coalescing branch (theundefinedside is unreachable at runtime because the record is exhaustive, but TypeScript’snoUncheckedIndexedAccessforces the syntax — see §5 point 3). A direct test callingcanTransitionwith a known-present key covers the truthy side; the falsy side is dead code at runtime. To force 100% branch coverage without anistanbul ignorecomment, the implementation drops the?? []in favor of a pre-computedReadonlyMap<TaskState, ReadonlySet<TaskState>>lookup, which has noundefinedbranch (Map.get().has()is a single boolean operation). See §7. - Both branches of the
if (!canTransition(...))intransition. - Constructor branches in
InvalidTransitionError(none — single branch).
§7. Implementation posture
To hit 100% branch coverage without istanbul ignore comments:
- Expose
VALID_TRANSITIONSasReadonly<Record<TaskState, readonly TaskState[]>>(for external introspection — §1 demands this). - Internally, derive a
ReadonlyMap<TaskState, ReadonlySet<TaskState>>at module load (one-time, frozen).canTransitionuses the map + set for O(1) lookup with no nullish-coalescing branch. (AMap.get()that returnsundefinedis technically a branch, but the map is built from the same record so membership is guaranteed — still, to stay bulletproof, the fallback path is covered by casting the key toTaskStatefirst and usingSet.prototype.haswhich is boolean-returning and unambiguous.) - Alternative: keep the record lookup and accept that the
?? []false branch is dead. Runjest --coverageand verify branch = 100%. If not, switch to the Map form.
The packet picks between these two on the implementation spike. The contract is agnostic — both satisfy this contract identically.
§8. Non-contract (what this module does NOT do)
- No persistence. This module has no dependency on
src/db/index.tsor any SQLite surface. Thetaskstable is P0.3.2’s concern. - No event emission. No
EventEmitter, no callbacks, no hooks. State transitions are synchronous and silent. - No logging. No
console.*, noprocess.stderr.write, no structured logger. The module is silent. - No MCP registration. No tool handlers, no JSON-RPC. The module exports pure functions only; the MCP
task_transitiontool (P0.3.4) importstransition+InvalidTransitionErrorand wraps them. - No retry bookkeeping. The backward edge
VERIFY → GATHERexists as an allowed transition. Attempt-counter tracking (e.g. “this is the third GATHER retry”) lives in the caller (P0.3.2 repository layer) — probably as aretry_countcolumn on the tasks row. - No cancellation-reason tracking. The edge
* → CANCELLEDis allowed; capturing a reason string (e.g.verify_permanent_fail) is the caller’s job, stored on the tasks row. - No kanban-lifecycle mapping. The donor vocabulary (
backlog | todo | in_progress | blocked | review) is explicitly heritage — mapping to β happens in ν Integrations (out of scope for Phase 0.3).
§9. Acceptance-criteria trace
Maps the task-prompt acceptance criteria to the contract sections above:
| # | Criterion | Contract section |
|---|---|---|
| 1 | 7 states + CANCELLED terminal side-branch, INIT → GATHER → ANALYZE → PLAN → APPLY → VERIFY → DONE | §2 + §3 |
| 2 | Transition map matches canonical diagram | §3 |
| 3 | Unlisted transitions throw InvalidTransitionError with {from, to, taskId} |
§4.2 + §4.3 |
| 4 | transition(task, newState) returns updated task or throws |
§4.2 |
| 5 | canTransition(from, to) returns boolean, no side effects |
§4.1 |
| 6 | DONE is terminal; any transition out throws |
§3 + §6.6 |
| 7 | CANCELLED is terminal; any transition out throws |
§3 + §6.6 |
| 8 | 100% branch coverage | §6.7 + §7 |
§10. Risks + open questions
- Record vs Map for lookup. Risk: the
?? []branch undernoUncheckedIndexedAccessis unreachable at runtime and may causebranch%< 100. Mitigation: packet specifies the Map fallback and commits to verifyingbranch === 100in Step 5. If the record form hits 100%, use it (simpler); otherwise use the Map. - Agent-prompt file drift.
docs/guides/implementation/task-prompts/p0.3-beta-task-pipeline.mdstill references donor kanban states. Not touching it — out of scope for P0.3.1. Flagged in audit §2b and audit §7 point 6. Fold into a later docs-polish task. - Future
TaskRowin P0.3.2.TaskShapeis deliberately minimal (id+state). P0.3.2 will defineTaskRowwith timestamps, writer, retry_count, etc. Thetransition<T extends TaskShape>generic accepts any superset — no API break. - The “8 vs 7” framing. The audit uses “8 states” (INIT + GATHER + ANALYZE + PLAN + APPLY + VERIFY + DONE + CANCELLED). The task prompt uses “7 states + CANCELLED as terminal side-branch” — same set, different phrasing. The implementation exports exactly the 8 literals; the documentation (audit, contract, packet, tests) uses “8” for the concrete tuple length and “7-state pipeline + CANCELLED” for the conceptual diagram. No mismatch.
Proceed to Step 3 (Packet).