Files
suaveplan/openspec/changes/fsm-completion/proposal.md
2026-05-27 23:29:53 +01:00

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.