Skip to content

State hardening: discriminated unions → transition maps → property testing #18

Description

@Garabed96

Problem

ctx-plugin has several stateful subsystems where bugs manifest as either illegal shapes (impossible combinations of fields) or illegal sequences (valid states reached in the wrong order). Traditional example-based tests cover "this should work" but not "this should never happen."

The spectrum

Booleans/strings    →  Discriminated unions     →  Transition map        →  Property testing
"anything goes"        illegal shapes blocked      illegal sequences blocked   "prove it never breaks"

Phase 1 — Illegal shapes (discriminated unions)

Problem: State represented as loose strings or boolean flags allows impossible combinations at the type level.

Solution: Refactor to discriminated unions. TypeScript enforces correctness at compile time. No new dependencies.

// Before: any combination is representable
let status: string = 'draft';
let worktree: string | null = null;
let result: string | null = null;

// After: only valid shapes exist
type PlanState =
  | { status: 'draft'; spec: string }
  | { status: 'approved'; spec: string; approvedBy: string }
  | { status: 'executing'; spec: string; worktree: string }
  | { status: 'complete'; spec: string; result: string }

Scope: Audit these 4 subsystems, refactor where state is strings/booleans:

  • Plan lifecycle
  • Worktree lifecycle
  • Session handoff
  • Subagent dispatch

Done when: All 4 subsystems use discriminated unions. No string state fields remain.


Phase 2 — Illegal sequences (transition maps)

Problem: Unions prevent illegal shapes but not illegal sequences. TypeScript won't stop draft → complete — the shape is valid, the sequence is wrong.

When this matters: Only when skipping a step causes damage:

  • Plan goes draft → complete? Shipped unreviewed work.
  • Worktree goes active → killed without parking? Orphaned branch, lost changes.
  • Subagent goes pending → complete without running? Silent data loss.

If skipping a step is harmless, unions are enough. If it causes damage, add a transition map.

Solution: A transition() function (~10 lines per subsystem) with a declared map:

const transitions = {
  draft:     ['approved', 'cancelled'],
  approved:  ['executing', 'cancelled'],
  executing: ['complete', 'failed'],
  complete:  [],
  failed:    ['executing', 'cancelled'],
  cancelled: [],
};

function transition(current: Status, next: Status): Status {
  if (!transitions[current].includes(next)) {
    throw new Error(`Illegal transition: ${current}${next}`);
  }
  return next;
}

Done when: Subsystems where sequence bugs cause damage have transition maps. All state changes go through transition().


Phase 3 — Property-based testing against the machine

Problem: Even with transition maps, you're trusting that all callers use transition(). How do you prove the real system never violates the machine?

Solution: Model-based testing with fast-check:

  1. fast-check generates random sequences of actions
  2. Each action attempts a state transition
  3. The transition map is the oracle — if the real system accepts a move the map rejects, that's a bug

Spike: Plan lifecycle first. If it finds bugs example tests missed, roll out to worktree and session handoff.

Done when: Spike complete. Decision documented on whether to expand.


Success criteria

  • Phase 1: All 4 subsystems audited and refactored to discriminated unions
  • Phase 2: Transition maps added where sequence bugs cause damage (plan lifecycle minimum)
  • Phase 3: fast-check spike on plan lifecycle, results documented
  • Decision: expand phase 3 to other subsystems or close

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions