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:

  1. No skipping. ANALYZE has no edge to APPLY; the packet must exist. (Audit §2a · task-pipeline.md lines 35–37.)
  2. 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.)
  3. 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.)
  4. 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.)
  5. Cancel is reachable from every non-terminal state, including INIT (cancel-before-start is valid). Six cancel edges total — one per non-terminal state.
  6. Terminal states have zero outgoing edges. Any transition(task, *) call where task.state ∈ {'DONE', 'CANCELLED'} throws InvalidTransitionError, regardless of the target.
  7. Self-loops are invalid. No state transitions to itself. This includes DONE → DONE (rejected because DONE is terminal; any outgoing edge from DONE throws) and VERIFY → 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 → * except GATHER and CANCELLED. 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 true iff the ordered pair (from, to) is listed in the transition map (§3).
  • Returns false for 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).

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) is true, returns { ...task, state: to } — i.e. a new object with the updated state field, with all other fields shallow-copied from task. The input task is NOT mutated.
  • If canTransition(task.state, to) is false, throws InvalidTransitionError with { 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:

  • name is the literal string 'InvalidTransitionError' (set in the constructor, not inherited — Error.name defaults to 'Error' otherwise under subclassing in ts-jest ESM).
  • from, to, taskId are readonly instance fields mirroring the constructor arg.
  • message format: `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 Error holds.
  • instanceof InvalidTransitionError holds.
  • Standard Error.prototype.stack is 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

  1. as const on the tuple. TASK_STATES is declared with as const, yielding readonly [literal, literal, …]. This makes TaskState = (typeof TASK_STATES)[number] a union of literal strings.
  2. satisfies for static self-checks. A module-scoped const _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.
  3. Record<TaskState, …> for VALID_TRANSITIONS. The record type is exhaustive — dropping a key is a compile-time error. Under noUncheckedIndexedAccess, VALID_TRANSITIONS[from] is typed as readonly 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.
  4. Object.freeze on all module-scoped singletons. TASK_STATES is already readonly from as const; the record and set are Object.freezed. ReadonlySet<TaskState> is the exposed type for TERMINAL_STATES.
  5. Generic over TaskShape. transition<T extends TaskShape>(task: T, to: TaskState): T preserves the caller’s type. No any, no unknown, no casts.
  6. 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_STATES has length exactly 8.
  • new Set(TASK_STATES) equals new Set(['INIT', 'GATHER', 'ANALYZE', 'PLAN', 'APPLY', 'VERIFY', 'DONE', 'CANCELLED']).
  • TERMINAL_STATES has size 2 and equals new 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 each TaskState.
  • Out-of-terminal (14): (DONE, *) for 7 targets + (CANCELLED, *) for 7 targets (all 14 to values include both terminals, so (DONE, DONE) overlaps with the self-loop — deduplicated to 14 unique).
    • Actually: (DONE, *) is 7 pairs (excluding DONE → DONE which 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.
  • 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 }, call transition(task, to), assert:
    • Return value is a new object (!== original).
    • Return value has state === to.
    • Return value has id === 't1'.
    • Input task.state is unchanged.
  • 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) throws InvalidTransitionError.
  • 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 to values and assert each throws when from === 'DONE'.
  • CANCELLED exit — iterate over all 8 possible to values and assert each throws when from === '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:

  1. The .includes() truthy branch in canTransition.
  2. The .includes() falsy branch in canTransition.
  3. The VALID_TRANSITIONS[from] ?? [] nullish-coalescing branch (the undefined side is unreachable at runtime because the record is exhaustive, but TypeScript’s noUncheckedIndexedAccess forces the syntax — see §5 point 3). A direct test calling canTransition with a known-present key covers the truthy side; the falsy side is dead code at runtime. To force 100% branch coverage without an istanbul ignore comment, the implementation drops the ?? [] in favor of a pre-computed ReadonlyMap<TaskState, ReadonlySet<TaskState>> lookup, which has no undefined branch (Map.get().has() is a single boolean operation). See §7.
  4. Both branches of the if (!canTransition(...)) in transition.
  5. Constructor branches in InvalidTransitionError (none — single branch).

§7. Implementation posture

To hit 100% branch coverage without istanbul ignore comments:

  • Expose VALID_TRANSITIONS as Readonly<Record<TaskState, readonly TaskState[]>> (for external introspection — §1 demands this).
  • Internally, derive a ReadonlyMap<TaskState, ReadonlySet<TaskState>> at module load (one-time, frozen). canTransition uses the map + set for O(1) lookup with no nullish-coalescing branch. (A Map.get() that returns undefined is 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 to TaskState first and using Set.prototype.has which is boolean-returning and unambiguous.)
  • Alternative: keep the record lookup and accept that the ?? [] false branch is dead. Run jest --coverage and 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)

  1. No persistence. This module has no dependency on src/db/index.ts or any SQLite surface. The tasks table is P0.3.2’s concern.
  2. No event emission. No EventEmitter, no callbacks, no hooks. State transitions are synchronous and silent.
  3. No logging. No console.*, no process.stderr.write, no structured logger. The module is silent.
  4. No MCP registration. No tool handlers, no JSON-RPC. The module exports pure functions only; the MCP task_transition tool (P0.3.4) imports transition + InvalidTransitionError and wraps them.
  5. No retry bookkeeping. The backward edge VERIFY → GATHER exists 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 a retry_count column on the tasks row.
  6. No cancellation-reason tracking. The edge * → CANCELLED is allowed; capturing a reason string (e.g. verify_permanent_fail) is the caller’s job, stored on the tasks row.
  7. 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

  1. Record vs Map for lookup. Risk: the ?? [] branch under noUncheckedIndexedAccess is unreachable at runtime and may cause branch% < 100. Mitigation: packet specifies the Map fallback and commits to verifying branch === 100 in Step 5. If the record form hits 100%, use it (simpler); otherwise use the Map.
  2. Agent-prompt file drift. docs/guides/implementation/task-prompts/p0.3-beta-task-pipeline.md still 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.
  3. Future TaskRow in P0.3.2. TaskShape is deliberately minimal (id + state). P0.3.2 will define TaskRow with timestamps, writer, retry_count, etc. The transition<T extends TaskShape> generic accepts any superset — no API break.
  4. 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).


Back to top

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

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