16 KiB
FSM completion — hierarchy, parallel regions, history, deferred events, hooks, visualization
Package: @suaveplan/fsm
Status: PROPOSED
Type: spec-conformance + feature
Why
A 2026-05-02 audit of the genesis @suaveplan/fsm package against the upstream library at ../suaveplan/packages/universal/fsm/src (and against this package's own archived proposal openspec/archive/2026-04-19-enterprise-fsm/) found six advertised capabilities that ship in upstream but are entirely missing from genesis. CLAUDE.md describes the package as "typed finite state machine: FSMInterpreter<TContext>, createMachine(), transition guards + actions, history entries, nested state validation, assign()/pure() context helpers" and package.json description claims "hierarchical states, parallel regions, deferred events". Genesis ships only a flat interpreter (packages/flow/fsm/src/core/interpreter/fsm-interpreter.ts) — the FSM type system has placeholder slots for type: 'parallel' and states: Record<...> in StateDefinition but no executor honours them.
Concretely, six features upstream ship and genesis silently dropped:
- Hierarchical FSM —
HierarchicalStateMachine<TEvent, TContext>(203 L) with nested-state UML semantics, dot-path resolution, common-ancestor exit/enter chain, per-nodeonEnter/onExithooks. GenesisStateDefinition.statesandStateDefinition.initialexist as types but are unused. - Parallel regions —
ParallelStateMachine<TState, TEvent, TContext>(208 L) with independentRegions, sync points,combinedState,matchesAll. Genesistype: 'parallel'is a typed-only no-op. - History states —
HistoryStateManager(138 L) with shallow + deep history, snapshot/restore, clear-on-trigger. - Deferred event queue —
DeferredEventQueue<TState, TEvent, TContext>(152 L), bounded FIFO with replay-on-handle. - Per-state lifecycle hooks —
StateConfig.onEnter/onExiton the flatMachineConfig(the upstreamStateMachineships this on top ofentry/exitactions; genesis only shipsentry/exitwith no separateonEnter/onExitlistener channel and noenter/exitevent emission). - Visualization —
toMermaid()andtoDot()exporters (130 L).
Without these, every consumer that needs a non-trivial machine (workflow engines, drive sync, network connection state, vault unlock, OAuth flows) is forced to either (a) hand-roll a parallel composer / nested resolver, or (b) abandon @suaveplan/fsm and import a third-party. The first creates invisible drift; the second wastes the foundation work already in genesis. The genesis-original archive 2026-04-19-enterprise-fsm was approved on the strength of the hierarchical / parallel / history bullet list and the implementation never delivered them — this change closes that gap.
What changes
@suaveplan/fsm — additions
- ADDED:
HierarchicalStateMachine<TEvent, TContext>class — UML-style nested state machine with dot-path state resolution (statePath,stateSegments), common-ancestor exit/enter chain on cross-boundary transitions, per-nodeonEnter/onExithooks, guard/action pipeline,matchesPath(path)helper. - ADDED:
createHierarchicalMachine(config)factory. - ADDED:
HierarchicalConfig,HierarchicalStateNode,HierarchicalTransition,HierarchicalTransitionResultinterfaces. - ADDED:
ParallelStateMachine<TState, TEvent, TContext>class — orthogonal-region machine with independentRegions, shared context,getRegionState(id),combinedState,matchesAll(conditions),reset(),sendToRegionById(id, event),onSync(listener)for sync-point subscription. - ADDED:
createParallelMachine(config)factory. - ADDED:
ParallelConfig,RegionConfig,RegionTransition,SyncPoint,ParallelState,ParallelTransitionResultinterfaces. - ADDED:
HistoryStateManagerclass —register(parentPath, config),record(parentPath, childState),resolve(parentPath),clear/clearAll,snapshot/restore,processTransition(from, to)for clear-on-trigger; injectableClockper CLAUDE.md determinism rule (no module-scopeDate.now()). - ADDED:
createHistoryManager()factory. - ADDED:
HistoryStateConfig,HistoryEntry,HistoryType('shallow' | 'deep'). - ADDED:
DeferredEventQueue<TState, TEvent, TContext>class — bounded FIFO of deferred events;configureState(state, { defer }),shouldDefer(state, event),enqueue,processQueue(machine)(replay-on-handle),clearEvent(event); injectableClock. - ADDED:
createDeferredQueue(config?)factory. - ADDED:
DeferredQueueConfig,DeferredEvent,StateDeferConfiginterfaces. - ADDED:
FSMInterpreterlifecycle hook channels —onEnter(state, listener)/onExit(state, listener)subscription methods that fire afterentry/ beforeexitactions; emitenter/exitevents on the existing internalEventBus. Existingentry/exitaction contract unchanged (returning a new context still replaces it). - ADDED:
toMermaid(definition, options?)— MermaidstateDiagram-v2exporter; honourscurrentState,showGuards,showActions,direction: 'LR' | 'TD'. - ADDED:
toDot(definition, options?)— Graphviz DOT exporter with same options. - ADDED:
VisualizerOptionsinterface. - ADDED: New error codes on
FSM_CODES—INVALID_HIERARCHY,UNKNOWN_REGION,DEFERRED_QUEUE_OVERFLOW,INVALID_HISTORY_TYPE.
Subpath exports (additive)
- ADDED:
./hierarchy→HierarchicalStateMachine,createHierarchicalMachine, types. - ADDED:
./parallel→ParallelStateMachine,createParallelMachine, types. - ADDED:
./history→HistoryStateManager,createHistoryManager, types. - ADDED:
./deferred→DeferredEventQueue,createDeferredQueue, types. - ADDED:
./visualization→toMermaid,toDot,VisualizerOptions.
Existing surface — preserved
FSMInterpreter,createMachine,assign,pure,validateDefinition,FSM_CODES,FSMError, all existing types — unchanged.
Out of scope
- Actor-model semantics (spawn/send to child machines, mailbox, supervision) — upstream does not ship this; genesis will not introduce it without a separate proposal and a real consumer asking for it.
- SCXML wire format import/export — neither upstream nor genesis ships this; the visualization exporters cover the legible-output use case (Mermaid for docs, DOT for tooling).
- Time-based / delayed transitions (
after: { 5000: 'next' }-style) — upstream does not ship this; orthogonal feature better served by@suaveplan/timing's scheduler composed with explicitsend()calls in user code. - Async transition pipeline (
AsyncGuardFn,AsyncActionFn) — upstream'sfsm-execution.tsships this for the flat machine; genesis defers it to a follow-up because the existing genesis interpreter is sync-only and threading async through history / hierarchy / parallel multiplies surface beyond the audit's scope. Will be tracked separately asfsm-async-pipeline. - Migration of upstream's
Hooks<FsmHookMap>data-pipeline integration (@suaveplan/events/hooksinterceptors) — the genesisEventBusalready covers the listener case; the data-mutating interceptor pipeline is a separate@suaveplan/eventscapability being tracked inevents-hooks-completion.
Source mapping
This change is a migration from ../suaveplan/packages/universal/fsm/src/. There is no genesis enterprise/fsm.md audit doc, so source mapping cites the upstream package source directly.
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
HierarchicalStateMachine class |
packages/universal/fsm/src/hierarchy/hierarchical-fsm.ts |
L1-L196 | Verbatim port; thread injectable Clock instead of Date.now() for any timestamp emission |
createHierarchicalMachine factory |
packages/universal/fsm/src/hierarchy/hierarchical-fsm.ts |
L198-L203 | Verbatim |
HierarchicalConfig / HierarchicalStateNode / HierarchicalTransition / HierarchicalTransitionResult |
packages/universal/fsm/src/hierarchy/hierarchical-fsm.types.ts |
L1-L54 | Verbatim |
ParallelStateMachine class |
packages/universal/fsm/src/parallel/parallel-fsm.ts |
L1-L197 | Verbatim port; replace silent catch in checkSyncPoints (L188-L192) with structured logging via injectable LoggerLike |
createParallelMachine factory |
packages/universal/fsm/src/parallel/parallel-fsm.ts |
L199-L208 | Verbatim |
ParallelConfig / RegionConfig / RegionTransition / SyncPoint / ParallelState / ParallelTransitionResult |
packages/universal/fsm/src/parallel/parallel-fsm.types.ts |
L1-L74 | Verbatim |
HistoryStateManager class with shallow/deep + snapshot/restore |
packages/universal/fsm/src/history/history-state.ts |
L1-L133 | Verbatim port; replace Date.now() (L52) with injectable Clock.now() |
createHistoryManager factory |
packages/universal/fsm/src/history/history-state.ts |
L135-L138 | Verbatim |
HistoryStateConfig / HistoryEntry / HistoryType |
packages/universal/fsm/src/history/history-state.types.ts |
L1-L23 | Verbatim |
DeferredEventQueue class with replay-on-handle |
packages/universal/fsm/src/deferred/deferred-queue.ts |
L1-L143 | Verbatim port; replace Date.now() (L85) with injectable Clock.now() |
createDeferredQueue factory |
packages/universal/fsm/src/deferred/deferred-queue.ts |
L145-L152 | Verbatim |
DeferredQueueConfig / DeferredEvent / StateDeferConfig |
packages/universal/fsm/src/deferred/deferred-queue.types.ts |
L1-L24 | Verbatim |
toMermaid / toDot exporters |
packages/universal/fsm/src/visualization/fsm-visualizer.ts |
L1-L130 | Adapt input from upstream MachineConfig to genesis FSMDefinition |
VisualizerOptions |
packages/universal/fsm/src/visualization/fsm-visualizer.ts |
L13-L23 | Verbatim |
Requirements with no source line
| Requirement | Justification |
|---|---|
Per-state onEnter / onExit listener channels on FSMInterpreter |
Genesis-original gap: upstream StateMachine (flat) ships enter / exit listener events via FsmListenerMap, but genesis FSMInterpreter only has the internal transition event. This change wires enter / exit events on the existing internal EventBus, gated behind subscribe('enter', ...) / subscribe('exit', ...). Reasoning: matches upstream's contract without forcing callers to introduce Hooks until that capability is migrated separately. |
Injectable Clock for HistoryStateManager and DeferredEventQueue |
CLAUDE.md determinism rule (Critical rule 9, openspec/AGENTS.md §0a "Avoid Math.random() / Date.now() at module scope"). Upstream uses Date.now() directly; genesis must thread Clock from @suaveplan/types. |
New FSM_CODES entries — INVALID_HIERARCHY, UNKNOWN_REGION, DEFERRED_QUEUE_OVERFLOW, INVALID_HISTORY_TYPE |
Upstream throws plain Error for these conditions; genesis-wide policy (CLAUDE.md Critical rule 2 "every thrown error route through @suaveplan/error") requires registered codes. |
Visualization input adapter (genesis FSMDefinition → upstream MachineConfig shape) |
The two definition shapes differ: upstream is { states: TState[], transitions: TransitionDefinition[] } and genesis is { states: Record<StateValue, StateDefinition> }. The exporter walks the genesis shape directly, so the adapter is a pure-function rewrite of toMermaid / toDot against the genesis types. Logic-equivalent; output identical for equivalent inputs. |
Verification approach
Each requirement in the spec delta carries a **Verified by:** line pointing at concrete it(...) titles. Test files that will exist after this change ships:
packages/flow/fsm/src/hierarchy/hierarchical-fsm.test.ts— nested entry/exit chain, dot-path resolution, common-ancestor traversal, guard pipeline, cross-boundary transitionspackages/flow/fsm/src/hierarchy/hierarchical-fsm.spec-conformance.test.ts— UML-statechart contract: ancestors entered before descendants, descendants exited before ancestors, action runs between exit and enterpackages/flow/fsm/src/parallel/parallel-fsm.test.ts— region independence, sync points,matchesAll,combinedState,sendToRegionById,resetpackages/flow/fsm/src/parallel/parallel-fsm.spec-conformance.test.ts— sync-listener structured-error path, parallel-context-shared invariantpackages/flow/fsm/src/history/history-state.test.ts— shallow vs deep, snapshot/restore round-trip, clear-on-trigger, default fallbackpackages/flow/fsm/src/history/history-state.spec-conformance.test.ts— injectedClockstamps timestamps; restore drops keys with no registered configpackages/flow/fsm/src/deferred/deferred-queue.test.ts— defer/replay FIFO, max-size enforcement,clearEvent, re-entrant guard via_processingflagpackages/flow/fsm/src/deferred/deferred-queue.spec-conformance.test.ts—DEFERRED_QUEUE_OVERFLOWthrown by configurable enqueue mode; injectedClockdrivestimestamppackages/flow/fsm/src/core/interpreter/fsm-interpreter.lifecycle.test.ts—onEnter/onExitlisteners fire on transition; ordering vsentry/exitactionspackages/flow/fsm/src/visualization/fsm-visualizer.test.ts—toMermaidandtoDotround-trip a non-trivial definition; current-state highlight; guard / action labelspackages/flow/fsm/src/visualization/fsm-visualizer.spec-conformance.test.ts— DOT escaping; Mermaid direction option; produces parseable diagrams (regex-level pin)tests/fsm-public-surface.test.ts(cross-package) — pins the additive subpath-export surface so accidental removal is a deliberate spec change
Migration impact
- Existing
FSMInterpreterconsumers — no behavior change. The interpreter gainsonEnter/onExitlistener channels but the existingentry/exitaction contract is preserved verbatim. @suaveplan/fsmdirect dependents — none in the genesis monorepo (bunx turbo ls --filter=...@suaveplan/fsmreturns the package itself only).- Subpath imports — five new subpaths added (
./hierarchy,./parallel,./history,./deferred,./visualization); the root barrel re-exports from each. No removed or renamed exports. - Error-code consumers — additive only; existing five codes preserved.
Parity Checklist
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
Flat StateMachine with guards / actions / history |
(upstream fsm.ts) |
(genesis FSMInterpreter) |
Already on genesis; preserved |
Hierarchical FSM (HierarchicalStateMachine) |
YES | YES | This change |
Parallel regions (ParallelStateMachine) |
YES | YES | This change |
History states (HistoryStateManager) |
YES | YES | This change |
Deferred event queue (DeferredEventQueue) |
YES | YES | This change |
Per-state onEnter / onExit hooks |
YES | YES | This change wires them onto the existing genesis interpreter; entry / exit actions preserved verbatim |
Visualization (toMermaid, toDot) |
YES | YES | This change |
Async transition pipeline (AsyncGuardFn, AsyncActionFn, executeTransitionAsync) |
YES | NO | Scoped out — orthogonal sync-vs-async axis; threading async through history/hierarchy/parallel multiplies surface beyond this proposal. Tracked as separate fsm-async-pipeline change. |
Hooks<FsmHookMap> data-pipeline integration |
YES | NO | Scoped out — depends on @suaveplan/events/hooks migration which is its own change. The genesis EventBus covers the listener case. |
Snapshot / restore on flat StateMachine |
YES | NO | Already a known gap on the existing flat genesis interpreter; pre-existing on fsm-snapshot follow-up; not introduced by this change. |
Affected dependents
bunx turbo ls --filter=...@suaveplan/fsm enumerates only the package itself (no in-repo dependents).
@suaveplan/fsm(self) — [x] tests run / [x] docs reviewed / [ ] no action needed
No external dependents on genesis. The proposal is additive (no removed or modified exports), so no downstream sweep is required.