140 lines
16 KiB
Markdown
140 lines
16 KiB
Markdown
# 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 `Region`s, 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 `Region`s, 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.
|