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

184 lines
13 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: fsm (fsm-completion)
**Package**: `@suaveplan/fsm`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: Hierarchical FSM with nested states
The package SHALL provide a `HierarchicalStateMachine<TEvent, TContext>` class implementing UML-statechart nested-state semantics. State paths are addressed as dot-separated strings (e.g. `"active.editing.text"`); on a transition the machine SHALL exit nested states from leaf upward to the deepest common ancestor of source and target paths, run the transition action, then enter from the common ancestor down to the new leaf, executing each node's `onEnter` / `onExit` hook in that order. Initial child states resolve recursively via each node's `initial` property.
#### Scenario: nested initial path resolves on construction
- **WHEN** a config defines `states.active.children.editing.children.text` with `initial` chained at each level
- **THEN** `machine.statePath` returns `"active.editing.text"` immediately after construction and `runEnterChain` fires `onEnter` for `active`, `active.editing`, `active.editing.text` in that order
#### Scenario: cross-boundary transition exits to common ancestor
- **WHEN** the machine is in `"active.editing.text"` and a transition's target is `"active.viewing"`
- **THEN** `onExit` fires for `active.editing.text` then `active.editing`, the transition action runs, then `onEnter` fires for `active.viewing`
#### Scenario: guard blocks transition without modifying state
- **WHEN** a transition's `guard` returns `false`
- **THEN** `send()` returns `{ success: false, error: 'Guard blocked event "<event>"' }` and `statePath` is unchanged
**Verified by:** `src/hierarchy/hierarchical-fsm.test.ts::"resolves the nested initial path on construction"` AND `src/hierarchy/hierarchical-fsm.test.ts::"exits to the common ancestor on cross-boundary transitions"` AND `src/hierarchy/hierarchical-fsm.spec-conformance.test.ts::"orders enter/exit hooks per UML statechart contract"`
---
### Requirement: Parallel regions with independent active state per region
The package SHALL provide a `ParallelStateMachine<TState, TEvent, TContext>` class supporting multiple orthogonal regions. Each region holds its own active state but shares one `TContext`. The `send(event)` method SHALL dispatch the event to every region in declaration order; regions whose current state matches a transition `from` AND whose guard passes SHALL transition independently. The result reports the affected region IDs. Sync points fire a configured event when all listed regions reach their listed states; sync-point listeners that throw SHALL route through an injected `LoggerLike.warn` rather than being silently swallowed.
#### Scenario: regions transition independently on the same event
- **WHEN** two regions A and B both have a transition from their initial state on event `"X"`
- **THEN** after `send("X")` both region states update, `result.affectedRegions` contains both IDs, and the shared `context` reflects merged action mutations
#### Scenario: sendToRegionById scopes the event to a single region
- **WHEN** `sendToRegionById("A", "X")` is called
- **THEN** only region A's transitions are evaluated; region B's state is unchanged
#### Scenario: unknown region raises FSMError
- **WHEN** `sendToRegionById("does-not-exist", ...)` is called
- **THEN** the call throws `FSMError` with code `FSM_CODES.UNKNOWN_REGION`
#### Scenario: sync point fires when all conditions are met
- **WHEN** all regions reach the states listed in a `SyncPoint.conditions`
- **THEN** every subscribed `onSync` listener is invoked with the sync point's `event`
**Verified by:** `src/parallel/parallel-fsm.test.ts::"transitions all regions that handle the event"` AND `src/parallel/parallel-fsm.test.ts::"sendToRegionById scopes to one region"` AND `src/parallel/parallel-fsm.spec-conformance.test.ts::"throws FSM_CODES.UNKNOWN_REGION for unknown regionId"` AND `src/parallel/parallel-fsm.test.ts::"fires sync point listeners when matchesAll conditions hold"`
---
### Requirement: History states (shallow and deep)
The package SHALL provide a `HistoryStateManager` class that records the last-seen child state for a registered parent path. `register(parentPath, { type, defaultState })` accepts `type: 'shallow'` (remember immediate child) or `type: 'deep'` (remember full nested path). `record(parentPath, childState)` stamps the entry with `Clock.now()` from an injected `@suaveplan/types::Clock`. `resolve(parentPath)` returns the remembered state if present, otherwise the configured `defaultState`. `snapshot()` and `restore(data)` round-trip all entries; `restore` SHALL drop any keys whose `parentPath` is not currently registered.
#### Scenario: shallow history remembers the last immediate child
- **WHEN** `register("menu", { type: "shallow", defaultState: "main" })` is called and `record("menu", "settings")` is then called
- **THEN** `resolve("menu")` returns `"settings"`
#### Scenario: default fallback when no history recorded
- **WHEN** `register("menu", { type: "shallow", defaultState: "main" })` is called and no `record` follows
- **THEN** `resolve("menu")` returns `"main"`
#### Scenario: clear-on-trigger drops history entry
- **WHEN** `addClearTrigger("menu", "settings", "exit")` is registered, `record("menu", "settings")` is called, then `processTransition("settings", "exit")` is called
- **THEN** `has("menu")` returns `false`
#### Scenario: snapshot/restore preserves only registered keys
- **WHEN** `restore({ unregistered: { state: "x", timestamp: 0 }, menu: { state: "settings", timestamp: 1 } })` is called and only `"menu"` is registered
- **THEN** `resolve("menu")` returns `"settings"` and `has("unregistered")` returns `false`
#### Scenario: invalid history type raises FSMError
- **WHEN** `register("menu", { type: "wrong" as HistoryType, defaultState: "main" })` is called
- **THEN** the call throws `FSMError` with code `FSM_CODES.INVALID_HISTORY_TYPE`
**Verified by:** `src/history/history-state.test.ts::"shallow history remembers the last immediate child"` AND `src/history/history-state.test.ts::"falls back to defaultState when no history recorded"` AND `src/history/history-state.test.ts::"clear trigger drops history on matching transition"` AND `src/history/history-state.spec-conformance.test.ts::"restore drops keys not registered in current configuration"` AND `src/history/history-state.spec-conformance.test.ts::"throws FSM_CODES.INVALID_HISTORY_TYPE for unknown HistoryType"`
---
### Requirement: Deferred event queue with replay-on-handle
The package SHALL provide a `DeferredEventQueue<TState, TEvent, TContext>` class that holds events the current state cannot handle and replays them after the next successful transition. `configureState(state, { defer })` registers events to defer per state. `send(machine, event)` SHALL enqueue (returning `null`) if `shouldDefer(currentState, event)` is true, else call `machine.send(event)` and on success drain the queue in FIFO order. The queue is bounded by `DeferredQueueConfig.maxSize` (default 100). The `overflow` config SHALL accept `'drop-newest' | 'drop-oldest' | 'throw'`; on `'throw'`, exceeding capacity raises `FSMError(FSM_CODES.DEFERRED_QUEUE_OVERFLOW, ...)`. Timestamps SHALL come from an injected `Clock`. The `processQueue` method SHALL guard against re-entrancy via an internal `_processing` flag.
#### Scenario: event deferred when state is configured to defer it
- **WHEN** the machine is in state `S` configured to defer event `E`, and `queue.send(machine, E)` is called
- **THEN** the call returns `null`, `queue.size` is `1`, and `machine.state` is unchanged
#### Scenario: replay drains the queue after a successful transition
- **WHEN** event `E` is deferred in `S`, the machine then transitions out of `S` to `T` (where `T` does not defer `E`), and `queue.processQueue(machine)` is called
- **THEN** `machine.send(E)` is invoked once, the queue is empty, and the returned array contains the transition result
#### Scenario: max-size with `drop-oldest` evicts the head
- **WHEN** `maxSize: 2`, `overflow: 'drop-oldest'`, and three events are enqueued
- **THEN** the oldest event is dropped, `queue.size` is `2`, and the head of the queue is the second-enqueued event
#### Scenario: overflow `throw` raises FSMError
- **WHEN** `maxSize: 1`, `overflow: 'throw'`, and a second `enqueue` is attempted
- **THEN** `FSMError` with code `FSM_CODES.DEFERRED_QUEUE_OVERFLOW` is thrown
#### Scenario: re-entrant processQueue is a no-op
- **WHEN** `processQueue` is called recursively (e.g. from a transition action that calls back into the queue)
- **THEN** the inner call returns an empty array and the outer call drains the queue exactly once
**Verified by:** `src/deferred/deferred-queue.test.ts::"defers events configured for the current state"` AND `src/deferred/deferred-queue.test.ts::"replays queued events in FIFO order on successful transition"` AND `src/deferred/deferred-queue.spec-conformance.test.ts::"drop-oldest overflow evicts the head when capacity is reached"` AND `src/deferred/deferred-queue.spec-conformance.test.ts::"throw overflow raises FSM_CODES.DEFERRED_QUEUE_OVERFLOW"` AND `src/deferred/deferred-queue.test.ts::"re-entrant processQueue returns empty array"`
---
### Requirement: Per-state lifecycle hooks on FSMInterpreter
The `FSMInterpreter` SHALL expose `onEnter(state, listener)` and `onExit(state, listener)` subscription methods that fire **after** a state's `entry` action and **before** a state's `exit` action. Each method returns a disposer; calling the disposer SHALL remove the listener idempotently. Both channels emit `enter` / `exit` events on the existing internal `EventBus` so external observers can subscribe directly. The existing `entry` / `exit` action contract (returning a new context to replace it) SHALL be preserved unchanged.
#### Scenario: onEnter fires after entry action
- **WHEN** state `S` has both an `entry` action that mutates `context` and an `onEnter` listener registered
- **THEN** the `entry` action runs first, then the `onEnter` listener observes the mutated `context`
#### Scenario: onExit fires before exit action
- **WHEN** state `S` has both an `exit` action and an `onExit` listener registered, and the machine transitions out of `S`
- **THEN** the `onExit` listener is invoked before the `exit` action mutates context
#### Scenario: disposer removes the listener idempotently
- **WHEN** the disposer returned by `onEnter('S', listener)` is called twice
- **THEN** the listener is removed exactly once and the second call is a no-op (no error thrown)
**Verified by:** `src/core/interpreter/fsm-interpreter.lifecycle.test.ts::"onEnter listener fires after entry action with mutated context"` AND `src/core/interpreter/fsm-interpreter.lifecycle.test.ts::"onExit listener fires before exit action"` AND `src/core/interpreter/fsm-interpreter.lifecycle.test.ts::"disposer is idempotent on repeated invocation"`
---
### Requirement: Visualization exporters — toDot and toMermaid
The package SHALL provide two pure functions that serialize an `FSMDefinition<TContext>` to human-readable diagram syntax:
- `toMermaid(definition, options?)` produces a Mermaid `stateDiagram-v2` document.
- `toDot(definition, options?)` produces a Graphviz DOT digraph.
Both SHALL accept `VisualizerOptions` with `currentState?: StateValue`, `showGuards?: boolean`, `showActions?: boolean`, `direction?: 'LR' | 'TD'`. Both SHALL walk the genesis `FSMDefinition.states: Record<StateValue, StateDefinition>` shape recursively, emit one edge per `(state, event, target)` triple, label edges with `event`, optionally annotate with `[guard]` / `/ action` markers, and highlight `currentState` if provided. DOT output SHALL escape `"` and `\` in labels.
#### Scenario: toMermaid emits a parseable stateDiagram-v2
- **WHEN** `toMermaid(definition)` is called with a non-empty definition
- **THEN** the output starts with `stateDiagram-v2`, contains `[*] --> <initial>`, and contains one `<from> --> <to> : <event>` line per transition
#### Scenario: toDot escapes special characters in labels
- **WHEN** an event name contains `"` (e.g. `'click "X"'`)
- **THEN** the corresponding DOT edge label is rendered with `\"` escaping
#### Scenario: showGuards = false suppresses guard annotation
- **WHEN** a transition has a guard and `showGuards: false` is set
- **THEN** the rendered label is exactly the event name with no `[guard]` suffix
#### Scenario: currentState highlight in toDot
- **WHEN** `toDot(definition, { currentState: 'active' })` is called
- **THEN** the `"active"` node line includes `style="rounded,filled", fillcolor="#ffccff"`
**Verified by:** `src/visualization/fsm-visualizer.test.ts::"toMermaid emits valid stateDiagram-v2"` AND `src/visualization/fsm-visualizer.spec-conformance.test.ts::"toDot escapes quotes in edge labels"` AND `src/visualization/fsm-visualizer.spec-conformance.test.ts::"showGuards: false suppresses [guard] annotation"` AND `src/visualization/fsm-visualizer.test.ts::"toDot highlights currentState with fillcolor"`
<!-- markdownlint-enable MD024 -->