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:
- fast-check generates random sequences of actions
- Each action attempts a state transition
- 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
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
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.
Scope: Audit these 4 subsystems, refactor where state is strings/booleans:
Done when: All 4 subsystems use discriminated unions. No
stringstate 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:
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: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:
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