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

13 KiB

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"