Files
2026-05-27 23:29:53 +01:00

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:

  1. Hierarchical FSM — HierarchicalStateMachine<TEvent, TContext> (203 L) with nested-state UML semantics, dot-path resolution, common-ancestor exit/enter chain, per-node onEnter / onExit hooks. Genesis StateDefinition.states and StateDefinition.initial exist as types but are unused.
  2. Parallel regions — ParallelStateMachine<TState, TEvent, TContext> (208 L) with independent Regions, sync points, combinedState, matchesAll. Genesis type: 'parallel' is a typed-only no-op.
  3. History states — HistoryStateManager (138 L) with shallow + deep history, snapshot/restore, clear-on-trigger.
  4. Deferred event queue — DeferredEventQueue<TState, TEvent, TContext> (152 L), bounded FIFO with replay-on-handle.
  5. Per-state lifecycle hooks — StateConfig.onEnter / onExit on the flat MachineConfig (the upstream StateMachine ships this on top of entry/exit actions; genesis only ships entry/exit with no separate onEnter/onExit listener channel and no enter/exit event emission).
  6. Visualization — toMermaid() and toDot() 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-node onEnter / onExit hooks, guard/action pipeline, matchesPath(path) helper.
  • ADDED: createHierarchicalMachine(config) factory.
  • ADDED: HierarchicalConfig, HierarchicalStateNode, HierarchicalTransition, HierarchicalTransitionResult interfaces.
  • ADDED: ParallelStateMachine<TState, TEvent, TContext> class — orthogonal-region machine with independent Regions, 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, ParallelTransitionResult interfaces.
  • ADDED: HistoryStateManager class — register(parentPath, config), record(parentPath, childState), resolve(parentPath), clear/clearAll, snapshot/restore, processTransition(from, to) for clear-on-trigger; injectable Clock per CLAUDE.md determinism rule (no module-scope Date.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); injectable Clock.
  • ADDED: createDeferredQueue(config?) factory.
  • ADDED: DeferredQueueConfig, DeferredEvent, StateDeferConfig interfaces.
  • ADDED: FSMInterpreter lifecycle hook channels — onEnter(state, listener) / onExit(state, listener) subscription methods that fire after entry / before exit actions; emit enter / exit events on the existing internal EventBus. Existing entry / exit action contract unchanged (returning a new context still replaces it).
  • ADDED: toMermaid(definition, options?) — Mermaid stateDiagram-v2 exporter; honours currentState, showGuards, showActions, direction: 'LR' | 'TD'.
  • ADDED: toDot(definition, options?) — Graphviz DOT exporter with same options.
  • ADDED: VisualizerOptions interface.
  • 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 explicit send() calls in user code.
  • Async transition pipeline (AsyncGuardFn, AsyncActionFn) — upstream's fsm-execution.ts ships 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 as fsm-async-pipeline.
  • Migration of upstream's Hooks<FsmHookMap> data-pipeline integration (@suaveplan/events/hooks interceptors) — the genesis EventBus already covers the listener case; the data-mutating interceptor pipeline is a separate @suaveplan/events capability being tracked in events-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 transitions
  • packages/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 enter
  • packages/flow/fsm/src/parallel/parallel-fsm.test.ts — region independence, sync points, matchesAll, combinedState, sendToRegionById, reset
  • packages/flow/fsm/src/parallel/parallel-fsm.spec-conformance.test.ts — sync-listener structured-error path, parallel-context-shared invariant
  • packages/flow/fsm/src/history/history-state.test.ts — shallow vs deep, snapshot/restore round-trip, clear-on-trigger, default fallback
  • packages/flow/fsm/src/history/history-state.spec-conformance.test.ts — injected Clock stamps timestamps; restore drops keys with no registered config
  • packages/flow/fsm/src/deferred/deferred-queue.test.ts — defer/replay FIFO, max-size enforcement, clearEvent, re-entrant guard via _processing flag
  • packages/flow/fsm/src/deferred/deferred-queue.spec-conformance.test.ts — DEFERRED_QUEUE_OVERFLOW thrown by configurable enqueue mode; injected Clock drives timestamp
  • packages/flow/fsm/src/core/interpreter/fsm-interpreter.lifecycle.test.ts — onEnter / onExit listeners fire on transition; ordering vs entry / exit actions
  • packages/flow/fsm/src/visualization/fsm-visualizer.test.ts — toMermaid and toDot round-trip a non-trivial definition; current-state highlight; guard / action labels
  • packages/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 FSMInterpreter consumers — no behavior change. The interpreter gains onEnter / onExit listener channels but the existing entry / exit action contract is preserved verbatim.
  • @suaveplan/fsm direct dependents — none in the genesis monorepo (bunx turbo ls --filter=...@suaveplan/fsm returns 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.