Skip to content

Spec 005 — State Machines

Status: Drafting. Post-v1 per 000 §Scope and roadmap. Builds on the foundation hooks in 002-Frames.md §State machines are just event handlers.

Why this Spec exists: state machines and actors are in the pattern because constrained execution models are easier to reason about than Turing-complete control flow. A finite state machine has an enumerable state space and a discrete transition relation; an actor system bounds concurrency to message-passing across well-defined boundaries with run-to-completion semantics. This is disproportionately valuable for AI use — an AI can fully simulate a finite machine; it cannot fully simulate a free-form imperative program. The cost is some expressiveness; the benefit is an execution model that survives mechanical reasoning.

For where Levels 1–4 sit in relation to the rest of the runtime (registrar, frame container, sub-cache, substrate adapter, trace bus), see Runtime-Architecture.

:spawn and :spawn-all (state-machine actors) are managed external effects — per Managed-Effects, the surface MUST satisfy the eight properties (effect-as-data, framework-owned actor lifecycle, structured failure taxonomy under :rf.machine/*, trace-bus observability, :sensitive? / :large? composition, built-in retry / abort / teardown via :after / :always / state-exit, in-flight actor registry, per-frame interceptor scoping).

Abstract

A state machine in re-frame2 is an event handler whose body interprets a transition table. Machines are registered as event handlers via reg-event-fx + make-machine-handler; the registered handler is the entire surface. The framework's machine-specific hooks live in 002 — drain semantics, the snapshot shape, the inspection trace surface, the :raise reserved fx-id (machine-internal) that the machine handler routes locally, and the :rf.machine/spawn / :rf.machine/destroy fx-ids (canonical actor-lifecycle).

For readers familiar with xstate, §Lessons from xstate at the end of this spec lists the divergences inline and forward-points to CP-5-MachineGuide §Lessons from xstate for the full divergence table.

Why machines

Machines serve two distinct use cases:

  1. High-level workflow. Multi-step user flows (signup → verify → onboard → home), modal dismissal logic, wizard navigation. Without machines these get smeared across many event handlers and an :app/screen keyword in app-db; the smearing is the pain.
  2. Low-level protocols. Async resource lifecycles (HTTP request: idle → loading → success/error/retry), websocket connection states, animation transitions. Without machines these live as ad-hoc keywords in some sub-tree of app-db, with handlers that have to remember "if state is :loading, ignore another :fetch."

Both want the same primitive but the ergonomics matter differently — workflow machines are few and named (one per major subsystem); protocol machines may have many concurrent instances (one per active resource). The same make-machine-handler factory covers both: a singleton machine is registered at boot via reg-event-fx; a dynamic instance is registered at run time via the [:rf.machine/spawn ...] fx (per §Spawning — dynamic actors).

Naming — :state and :data

The snapshot is {:state :data}:

  • :state — the FSM-keyword (:idle, :editing, :loading, ...). Discrete; enumerable; what xstate calls state.value.
  • :data — extended state, the machine's own private memory: a plain map distinct from app-db. The term tracks FSM literature and Erlang gen_statem's "state data"; xstate calls the same slot "context".

The pair {:state :data} reads as the natural English idiom and matches a vocabulary that's well-represented in AI training corpora. We use :data to avoid the existing "context" overloading in re-frame's interceptor pipeline and React-context affordances.

:data is the canonical destructure key for the machine's working memory. Per §Guards / §Actions and, every machine callback receives a SINGLE context-map argument with :data (the machine's :data slot — a plain map), :event (the inbound event vector), and on opt-in via destructure also :state (the discrete FSM-keyword) and :meta (any user :meta declared on the snapshot). Bodies that read individual fields destructure: (fn [{:keys [data event]}] (:circle-id data)), NOT (get-in snapshot [:data :circle-id]).

Snapshot shape

{:state <fsm-keyword-or-path-or-region-map>   ;; :idle | [:checkout :payment :credit-card] | {:data :loading :form :neutral}
 :data  <map>                                  ;; the machine's private memory
 :tags  #{<keyword>, ...}                      ;; OPTIONAL — runtime-projected union of every active state's :tags; see §State tags
 :meta  {<optional> ...}}                      ;; reserved for :rf/snapshot-version etc.

The runtime ALSO stamps a closed set of :rf/* slots inside the snapshot (some at the snapshot root, some inside :data) — the spawn-id counter, the :after-epoch counter, the bootstrap flag, the spawned-actor address keys, etc. These are framework-owned and catalogued in Conventions §Reserved snapshot-internal keys; user code MUST NOT write under them.

:state has three arms, disambiguated by the machine's declared shape:

  • Flat machines:state is a single FSM-keyword (:idle, :editing, :loading, ...). Equivalent to xstate's state.value for a non-compound machine. The flat-machine grammar in §Transition table grammar and the Circle Drawer worked example use this form.
  • Compound machines:state is a vector path from the root state-node to the active leaf ([:authenticated :cart :browsing]). The vector form is required when any state in the machine is a compound state (declares its own nested :states); it disambiguates "which :browsing?" when the same leaf-keyword could appear under multiple parents. See §Hierarchical compound states.
  • Parallel-region machines:state is a map of region-name → that region's keyword-or-vector-path ({:data :loading :form :neutral :mode :active}). The map form is required when the machine declares :type :parallel; every region is active simultaneously, and each region's value follows the flat-or-compound arms above. See §Parallel regions.

Implementations accept all three forms on read. The flat / compound arms normalise to a vector path internally for uniform manipulation; the parallel arm is preserved as a map (each region runs its own state-tree). Per (Nine States Stage 2), the parallel-region arm became first-class.

Stability invariants — every conformant implementation upholds these so snapshots survive the wire (SSR hydration per 011) and the time-axis (Tool-Pair epoch replay):

  1. Print/read round-trip. (read-string (pr-str snapshot)) returns an =-equal value. No functions, atoms, JS objects, or other unprintable values may appear in :data. Implementations that need such things keep them at a sibling path in app-db, not inside the snapshot.
  2. No in-flight microstate. Snapshots represent committed state only. A machine mid-transition is not snapshotted; the runtime takes snapshots at the boundaries of machine-transition.
  3. Stable shape across re-registration. Hot-reloading a machine handler does not invalidate existing snapshots whose :state is still a member of the new definition's :states. Snapshots whose :state is no longer present transition to the new :initial and emit :rf.error/machine-state-not-in-definition (per Spec 009 §Trace events; older drafts spelled this :rf.warning/machine-state-not-in-definition, the :rf.error/ form is canonical).
  4. Versioned via :meta. When a definition's transition shape changes incompatibly, bump :meta :rf/snapshot-version on the definition. Restore compares the snapshot's :rf/snapshot-version to the definition's; mismatch emits :rf.error/machine-snapshot-version-mismatch (per Spec 009 §Trace events; older drafts spelled this :rf.warning/machine-snapshot-version-mismatch).

Restore semantics

The contract is "replace app-db at [:rf/runtime :machines :snapshots <id>] with the snapshot value." There is no separate restore-callback, no init event firing again — the snapshot is the state. SSR hydration (011) and Tool-Pair epoch restore both use this contract.

See Spec-Schemas §:rf/machine-snapshot.

Reserved snapshot-internal keys

Beyond the user-facing {:state :data :tags? :meta?} shape above, the runtime stamps a closed set of :rf/*-prefixed slots inside the snapshot (some at the snapshot root, some inside :data, one inside :meta) to thread per-machine bookkeeping through pure transitions and the SSR-survivable persisted state. These slots are framework-owned: user code MUST NOT write under them; conformance fixtures that pin them MUST treat them as the runtime's by-product. The set is fixed-and-additive — existing names cannot be repurposed; new keys are added by Spec change.

Reserved key Location Value When written / cleared
:rf/spawn-counter snapshot root {<id-prefix> <int>} per-prefix integer-counter map Bumped by the pure spawn-id allocator on every :spawn / :spawn-all / hand-emitted :rf.machine/spawn so id sequencing is deterministic from the snapshot. Stamped as {} at snapshot synthesis; persists across pr-str / read-string.
:rf/machine-type snapshot root <machine-id> keyword OR an inline-:definition spec map Stamped by the spawn-fx onto a SPAWNED actor's snapshot (absent on singleton snapshots) so the actor's TYPE — and therefore its handler — is recoverable purely from the (revertible) snapshot. A :machine-id spawn stores the registered TYPE keyword (the type outlives instances); an inline :definition spawn stores the spec map directly. The lazy resolver reads it on dispatch to re-materialise the actor's handler; the epoch restore precondition reads it to admit a spawned-actor snapshot as a valid restore target. This is what makes a spawned actor's LIVENESS a pure function of app-db (per §Liveness is derived from app-db). Persists across pr-str / read-string and through SSR hydration / epoch replay. Per (rf2-a2sn1).
:rf/bootstrap-pending? snapshot root true (else absent) Stamped at snapshot synthesis (singletons) and by the spawn-fx (spawned actors). The first event addressed to the machine runs the initial-entry cascade, then clears the slot via dissoc. NEVER true on a snapshot that has already processed an event. The slot survives pr-str round-trip so a snapshot persisted mid-bootstrap (the SSR boundary case) resumes correctly. Per §Initial-state :entry fires on machine creation (start).
:rf/finished? snapshot root true (else absent) Transient. Set by the lifecycle-handler boundary (NOT by apply-transition-once) when the post-transition snapshot's active leaf declares :final? true — or, for parallel-region machines, when every region's active leaf is :final?. The lifecycle handler reads it to fire :on-done + auto-destroy, then the snapshot is dissoc'd from [:rf/runtime :machines :snapshots <id>]. Pure machine-transition calls (conformance corpus, JVM pure-fn tests) see snapshots free of this flag. Per §Final states and.
:rf/after-epoch inside :data {<decl-path-vector> <non-negative int>} The wall-clock :after-timer epoch map for flat / compound machines, keyed per scheduling node (its declaring state path), per §Delayed :after transitions §Hierarchy interaction. commit-snapshot bumps ONLY the entries for nodes the transition exits or enters; a still-active parent above the LCA keeps its entry — and thus its in-flight :after timer — across a child-only sibling transition. The synthetic :rf.machine.timer/after-elapsed event carries [delay-key epoch decl-path]; the runtime fires the transition iff the scheduling node is still on the active path AND the carried epoch equals that node's current per-path entry. (Per-node, not a single scalar — a single scalar could not keep a parent's timer live across a child transition that itself bumps the counter; per Spec 005 §Hierarchy interaction the per-level tracking is normative.)
:rf/after-epoch-by-region inside :data {<region-name> {<decl-path-vector> <non-negative int>}} Per-region :after-timer epoch map for parallel-region machines, per §Per-region :always / :after / :spawn scoping. Replaces :rf/after-epoch when :type :parallel; each region carries its own per-decl-path map so a sibling region's transition does not invalidate this region's in-flight timers via the shared :data slot.
:rf/self-id inside :data <spawned-machine-id> keyword Stamped by the spawn-fx on a spawned actor's initial :data so the actor knows its own address (e.g. for self-:dispatch). Equal to the gensym'd spawned id; absent on singleton-machine snapshots.
:rf/parent-id inside :data <parent-machine-id> keyword Stamped by the spawn-fx on a declarative-:spawn / :spawn-all spawned actor's initial :data. The finalize-cascade reads it to locate the parent's snapshot for :on-done. Absent on hand-emitted (non-declarative) spawns.
:rf/spawn-id inside :data <vector-of-keywords> — absolute prefix-path of the :spawn-bearing state node Stamped by the spawn-fx on a declarative-:spawn / :spawn-all spawned actor's initial :data. Together with :rf/parent-id it addresses the runtime spawn-registry slot at [:rf/runtime :machines :spawned <parent-id> <invoke-id>].
:rf/spawn-all-id inside :data <vector-of-keywords>:spawn-all-bearing state's prefix-path Stamped by the spawn-fx on each child of a :spawn-all. The finalize-cascade uses it to locate the parent's join bookkeeping at [:rf/runtime :machines :spawned <parent-id> <invoke-all-id>].
:rf/spawn-all-child-id inside :data child-machine-id keyword (the :id of the child in the parent's :spawn-all :children map) Stamped alongside :rf/spawn-all-id so the finalize-cascade can mark exactly which child finished.
:rf/snapshot-version inside :meta int Versioning slot for snapshot/definition compatibility (invariant 4 above). When a definition's transition shape changes incompatibly, the author bumps :meta :rf/snapshot-version; restore compares the snapshot's version against the definition's and emits :rf.warning/machine-snapshot-version-mismatch (or, on the epoch-restore path, :rf.epoch/restore-version-mismatch) on disagreement. Per Spec-Schemas §:rf/machine-snapshot and Tool-Pair §Time-travel.

Persistence posture. The transient slots are :rf/bootstrap-pending? (cleared on first event) and :rf/finished? (set transiently at the lifecycle-handler boundary; never persisted because the snapshot is dissoc'd on the same drain). All other slots ride the snapshot across pr-str / read-string and through SSR hydration (011) and Tool-Pair epoch replay.

Sibling vocabulary — runtime-stamped machine-spec keys (NOT snapshot-internal). The runtime ALSO stamps a small set of :rf/* slots on the live machine-spec value (the runtime's record threaded through apply-transition-once and the lifecycle handlers) — these are NOT snapshot-internal and do NOT persist; they are reconstructed at handler-call time from the registrar and the dispatched event:

  • :rf/frame — the owning frame's id (defaulting to :rf/default)
  • :rf/platform — the active platform (:client / :server) per 011
  • :rf/parent-id — the machine's own id (or the parent's id for spawned actors), used for trace addressing
  • :rf/region — present iff the spec is a synthetic region-machine of a :type :parallel parent; the region-name keyword scoping :after-epochs per §Per-region scoping
  • :rf/transition-pure — the sentinel parent-id used by the pure-transition path so intercept-invoke-all-event (and analogous join-bookkeeping interceptors) recognises a no-op call and short-circuits without consulting app-db. Stamped only by callers exercising the pure-fn machine-transition (conformance corpus, JVM pure-fn tests, SSR machine-pure surface); absent during normal handler-driven drains.

These spec-level keys are reachable from callbacks via the unified context-map's :meta key — (fn [{:keys [data event meta]}] ...) reads the value directly. No opt-in required (the context map is uniform across every callback slot).

Open-map invariant. Snapshots are open maps: user :data keys at any depth are fine. The runtime-reserved set above is the closed subset of :rf/*-prefixed slots the runtime owns inside the snapshot. The migration agent flags any user write to [:rf/runtime :machines :snapshots <id> :data :rf/<reserved>] or to [:rf/runtime :machines :snapshots <id> :rf/<reserved>] as a collision.

Why the reserved keys live at the same level as user data — and the :rf/runtime consolidation that wasn't. The audit (Finding 4) flagged that 7 reserved :rf/* keys interleave with user data inside :data, and that a single (:rf/runtime data) sub-map would consolidate the surface a user sees when introspecting a snapshot in dev tools. The consolidation was considered and deferred — it is principled but requires (a) a :rf/snapshot-version bump (every persisted snapshot in localStorage / Tool-Pair epoch history needs migration), (b) a conformance-fixture sweep (every fixture that pins a :rf/* slot under :data needs the new path), and (c) a migration-agent rule update. The cost is real and the win (smaller user-visible introspection surface) is presentational rather than load-bearing. The current shape is defensible: the :rf/* namespace is a documented closed catalogue, the open-map invariant catches collisions at the migration-agent boundary, and tools that present snapshots in a dev UI can filter the closed :rf/* set out of the user-data view without a schema change (10x's app-db panel and Xray's snapshot lens both do this). The consolidation remains a candidate for a future incompatibility-budgeted change (the next time a :rf/snapshot-version bump is needed for orthogonal reasons), at which point the sweep cost is amortised. Per audit Finding 4.

The same catalogue is mirrored in Conventions §Reserved snapshot-internal keys (machine runtime) for cross-spec discoverability.

Reserved synthetic events

The machine runtime emits a small closed set of synthetic event vectors that are not user-dispatched and do not come from reg-event-* — they are framework-generated and threaded through the standard dispatch pipeline so they appear in traces, conformance fixtures, and tooling exactly like any other event. User code MUST NOT register a handler for these names or hand-dispatch them; they are reserved for the runtime. The set is fixed-and-additive — existing names cannot be repurposed; new ones are added by Spec change.

Synthetic event vector Source Reaches Purpose
[:rf.machine.spawn/spawned] The :rf.machine/spawn fx handler (per §Synthetic [:rf.machine.spawn/spawned] on spawn) when the spawn args carry no :start. The spawned actor — dispatched as [<spawned-id> [:rf.machine.spawn/spawned]] after the initial-entry cascade settles. Generic-child kick-off shape. Machines that need to do work on spawn declare :on :rf.machine.spawn/spawned (legacy kick-off form) or :entry :fire-request on the initial state (canonical ; the synthetic event still flows. A non-handler simply has no clause for it — and because the id is reserved-:rf/* framework lifecycle traffic it does not emit :rf.machine.event/unhandled-no-op, per the reserved-:rf/* carve-out in §Transition resolution).
[:rf.machine/start] The eager creation kick (dispatched by the surrounding program as [:machine-id [:rf.machine/start]]) AND the initial-entry cascade (per §Synthetic creation marker — [:rf.machine/start]). As the eager kick: the machine handler, which runs the initial-entry cascade then STOPS (a pure init-kick — never re-fed into the transition step). As the placeholder: the initial-entry :entry action(s), threaded as the context-map's :event value. Never reaches an :on map. The creation marker (xstate createActor(m).start() / xstate.init). :entry actions that need to distinguish creation from user-driven entry destructure :event and check the first element against :rf.machine/start.
[:rf.machine.timer/after-elapsed <delay-key> <epoch>] re-frame.machines.timer (per §Epoch-based stale detection). Scheduled via :dispatch-later at state entry for every :after entry; fires when the wall-clock window elapses. The parent machine — dispatched as [<machine-id> [:rf.machine.timer/after-elapsed <delay-key> <epoch>]]. Handled by the machine handler's :after-dispatch path. Wire format between schedule-after-timer! and pick-after-transition. <delay-key> is the literal :after map key (a pos-int?, a subscription vector, or the resolved fn-output) that identifies which :after entry's timer fired; <epoch> is the value of :rf/after-epoch (or, for parallel-region machines, the region's slot in :rf/after-epoch-by-region) captured at scheduling time. The handler compares the carried <epoch> to the snapshot's current value; on mismatch the timer is stale and silently dropped (per §Epoch-based stale detection).

Conformance harnesses that dispatch these vectors directly (per the JVM pure-fn tests for :after timers) do so by emulating the runtime — they're exercising the same wire shape the runtime uses internally, not registering new handlers for these names.

Pure-call no-op shim (SSR / pure introspection)

re-frame.machines.join's intercept-invoke-all-event interceptor (the join-bookkeeping wiring per §Spawn-and-join via :spawn-all) reads from app-db at [:rf/runtime :machines :spawned <parent-id> <invoke-all-id>] to advance child-completion bookkeeping. When the interceptor is invoked through the pure-call path — exercising machine-transition directly without a live frame, i.e. the conformance corpus, JVM pure-fn tests, the SSR machine-pure surface — there is no [:rf/runtime :machines :spawned] slot seeded in app-db, so the interceptor would either error or write spurious join-state.

The contract: when no join-state is seeded for the active (invoke-all-id, child-id) pair, the interceptor returns its standard {:db db :fx} shape (a no-op effect-map) and emits no trace. Externally observable behaviour: pure-call invocations of a :spawn-all-bearing machine do NOT advance join bookkeeping; they exercise the transition reducer only. Authors of pure-call test corpora can rely on this — driving the parent through a sequence of events under machine-transition will run every transition, action, and entry/exit cascade, but the parent's :spawn-all child-completion bookkeeping is unobserved (since the runtime spawn registry isn't in scope).

The shim is keyed off the sentinel :rf/transition-pure parent-id stamp on the spec record (per the sibling-vocabulary list in §Reserved snapshot-internal keys) — the pure-call path stamps the sentinel; live-frame invocations stamp the actual parent's id. The interceptor short-circuits on the sentinel without consulting app-db.

Where snapshots live

Every machine snapshot lives at a fixed reserved path: [:rf/runtime :machines :snapshots <machine-id>] in the frame's app-db. The runtime owns this path; users do not pick a path per machine and make-machine-handler does not accept a :path key.

For the registration (rf/reg-event-fx :drawer/editor (rf/make-machine-handler {...})):

;; in the frame's app-db, after initialisation:
{:rf/runtime {:machines {:snapshots {:drawer/editor {:state :idle :data {:circle-id nil ...}}}}}}

Snapshot is lazily initialised. Registration creates the handler, not the snapshot. The first time the machine handler runs (the first dispatched event addressed to this id), the runtime resolves the snapshot via (or (get-in db [:rf/runtime :machines :snapshots <id>]) <initial-from-spec>) — so before the first event, (get-in app-db [:rf/runtime :machines :snapshots :drawer/editor]) returns nil and @(rf/sub-machine :drawer/editor) returns nil. The lifecycle trace :rf.machine.lifecycle/created (per 009) is emitted at registration to mark the handler's appearance in the registry — it does NOT imply the snapshot exists in app-db yet. Views that need to render before any event reaches the machine should treat nil as "not yet initialised" and tolerate it (or seed a fixed initial state via :on-create if appearance-without-event is required).

For a spawned actor whose gensym'd id is :request/protocol#42:

{:rf/runtime {:machines {:snapshots {:request/protocol#42 {:state :loading :data {:url "/foo"}}}}}}

:rf/runtime is the reserved app-db root (per Conventions §Reserved app-db keys); inside it, :machines :snapshots holds the per-machine snapshot map — a [:map-of :keyword :rf/machine-snapshot] keyed by the machine's registered id. User app-db code MUST NOT write under [:rf/runtime ...].

Why the locked path — the load-bearing reason is Goal 2 — Frame state revertibility: co-locating snapshots in app-db is the named mechanism by which machine state inherits revertibility. When a frame's value reverts, every machine snapshot reverts with it. A parallel ActorRef registry or a per-machine atom would put machine state outside the frame's value and break the goal. The five concrete consequences below all flow from that:

  1. Encapsulation. A machine's snapshot is its private state; the rest of app-db is the rest of the app. Co-locating all snapshots under one reserved key keeps the boundary visible at a glance.
  2. No path collisions. Two features that both want a [:foo :flow] machine cannot accidentally share a snapshot location. Ids are already unique within a frame; reusing them as the in-app-db key inherits that uniqueness for free.
  3. Tooling. (get-in (app-db-value frame-id) [:rf/runtime :machines :snapshots]) enumerates every live machine snapshot in one read. Pair tools, 10x, and conformance harnesses use this directly.
  4. Per-frame isolation is automatic. Each frame has its own app-db, and thus its own [:rf/runtime :machines :snapshots] map. The same machine id can exist in multiple frames; their snapshots are isolated by virtue of living in different frames' app-dbs (per 002 §Frames). Inside one frame, the id is unique.
  5. AI-amenability. "Where is the snapshot?" has one answer at all times. AIs do not need to consult per-machine metadata to find state.

Cost: feature-locality. Putting machine snapshots under a reserved global subtree means they don't sit alongside their feature's own app-db slice. A user inspecting [:auth ...] won't see the auth flow's machine snapshot there — it lives at [:rf/runtime :machines :snapshots :auth/login-flow]. This is a real cost. We accept it because the wins (uniform tool support, atomic revertibility per Goal 2 of 000-Vision, host-independent storage scheme, automatic SSR hydration / persistence / undo) require one structural location for machine state. Tooling can present a feature-local view (10x's app-db panel can render a "Machines" section adjacent to the feature's data) without compromising the storage scheme.

The runtime composes the snapshot-map's schema from the registered machines' :schema slots (per §Schema validation, the surface shipped under): (rf/app-schema-at [:rf/runtime :machines :snapshots]) returns [:map-of :keyword :rf/machine-snapshot], and per-machine entries refine :rf/machine-snapshot against each machine's declared :schema (which describes the :data shape). Violations surface at the :where :machine-data boundary — row 7 of the per-step recovery table per Spec 010 §Per-step recovery.

What the Single Store gives us for free

Because every machine's snapshot lives in app-db at [:rf/runtime :machines :snapshots <id>] — not in a parallel ActorRef registry, not in a per-machine atom — every facility re-frame already provides for app-db automatically extends to machines:

  • Undo / redo. An undo interceptor that snapshots app-db before/after a handler captures machine state along with everything else.
  • Time-travel debugging. Tool-Pair's epoch buffer records :db-before / :db-after on each drain; rewinding restores machines to their prior snapshot at no extra cost.
  • SSR hydration. The :rf/hydrate event replaces app-db with the server-supplied payload (per 011); machine snapshots ride along with the rest of the state — no separate hydration channel.
  • Persistence. Writing app-db to localStorage / IndexedDB / a server endpoint serialises machines too. Reloading deserialises them back into [:rf/runtime :machines :snapshots <id>] and the next event sees them.
  • Conformance fixtures. A fixture's :fixture/expect :final-app-db covers machine state without needing a machine-specific assertion.
  • Schema validation. (rf/reg-app-schema [:rf/runtime :machines :snapshots] ...) validates the whole machine map; per-machine :schema slots refine it against each machine's :data shape at the :where :machine-data boundary (per §Schema validation, shipped under ; row 7 of Spec 010 §Per-step recovery).
  • Trace replay. Tool-Pair epochs replay events against :db-before to reproduce a session; machine transitions replay along with everything else because their state is in the db.
  • Snapshot-and-restore. The epoch surface (epoch/restore-epoch + epoch/reset-frame-db!) captures app-db and reapplies it; machines come back with the rest of state. This holds for SPAWNED actors too, not just singletons: a spawned actor's liveness is its snapshot's presence in the (revertible) frame value (per §Liveness is derived from app-db), so rewinding past a spawn removes the actor and rewinding past a destroy re-materialises a working handler — with zero registrar drift.

The argument: the Single Store invariant pays off again. Every app-db capability re-frame already ships extends to machines without a single line of machine-specific implementation. This is why machine snapshots live in the db rather than in a parallel substrate. The undo / redo, time-travel, persistence, and snapshot-restore items above are not coincidences — they are the concrete consequences of the named Goal 2 — Frame state revertibility when machine state lives inside the frame's persistent value.

Transition table grammar

A transition table is pure data. Top-level shape:

{:initial <fsm-keyword>                     ;; required — initial state
 :data    {<initial data>}                  ;; optional — initial data map
 :schema  <validator-schema>                ;; optional — validates `:data` at the `:where :machine-data` boundary (see §Schema validation)
 :guards  {<keyword> <fn>, ...}             ;; optional — machine-local named guard impls
 :actions {<keyword> <fn>, ...}             ;; optional — machine-local named action impls
 :states  {<fsm-keyword> <state-node>, ...} ;; required
 :on      {<event-id> <transition>, ...}    ;; optional — top-level fallback
 :meta    {<user-keys> ...}}                ;; optional — e.g. :rf/snapshot-version

The snapshot's location in app-db is [:rf/runtime :machines :snapshots <id>] — runtime-managed and not part of the transition-table grammar. See §Where snapshots live.

The transition-table spec map MUST NOT carry :id. A machine's id is the surrounding registration's event-id (the first arg to reg-event-fx or, for dynamic instances, the gensym'd id allocated by the [:rf.machine/spawn ...] fx), not a field on the spec map. The runtime derives the id at handler-call time from the dispatched event vector's first element. Keeping :id out of the spec map keeps it a pure description of behaviour and lets the same spec value register against multiple ids if the application wants two independent machines with the same body.

Transition table top-level keys

Key Where Notes
:initial top-level required — the initial FSM-keyword
:data top-level optional — initial data map
:schema top-level optional — validates the machine's :data slot at every macrostep boundary + at bootstrap; failures emit :rf.error/schema-validation-failure :where :machine-data and roll back the cascade. See §Schema validation.
:guards top-level optional — {<keyword> <fn>} map of machine-local named guards; referenced by keyword from :guard slots
:actions top-level optional — {<keyword> <fn>} map of machine-local named actions; referenced by keyword from :action / :entry / :exit slots
:meta top-level optional — e.g. :rf/snapshot-version
:states top-level required — map of FSM-keyword → state node
:on per-state and top-level event-driven transition map
:on keys event keyword or :* wildcard wildcard matches any unhandled event
:entry, :exit per-state one fn or one keyword reference into the machine's :actions map
:spawn per-state declarative spawn-on-entry / destroy-on-exit child actor — sugar that desugars at registration time per §Declarative :spawn
:spawn-all per-state declarative spawn-and-join of N parallel child actors (sugar over N :spawns plus a join condition) — see §Spawn-and-join via :spawn-all
transition shape per-event {:target :guard :action :meta}
multiple-candidate transitions per-event vector of guarded specs, first-match-wins
self-transitions per-event :target :same-state (external) or omit :target (internal)
per-state :meta per-state tooling-visible, e.g. {:terminal? true}

State nodes

Every state in :states is a map. The complete state-node grammar — every key the v1 CLJS reference recognises:

{:on      {<event-id> <transition>, ...}    ;; event-driven transitions
 :always  [<guarded-transition>, ...]        ;; eventless transitions (see §Eventless `:always`)
 :after   {<delay> <transition>, ...}        ;; delayed transitions (see §Delayed `:after`)
 :tags    #{<keyword>, ...}                  ;; runtime-projected onto snapshot's :tags (see §State tags)
 :entry   <fn-or-keyword>                    ;; ran on entering this state; keyword resolves into :actions map
 :exit    <fn-or-keyword>                    ;; ran on exiting this state; keyword resolves into :actions map
 :spawn  <invoke-spec>                      ;; spawn child on entry; destroy on exit (see §Declarative :spawn)
 :spawn-all <invoke-all-spec>               ;; spawn N children in parallel and join (see §Spawn-and-join via :spawn-all)
 :final?  true                               ;; leaf-only — entering this state terminates the machine (see §Final states)
 :output-key <keyword>                       ;; iff `:final?` — designate which `:data` key is reported back via parent's `:on-done`
 :initial <fsm-keyword>                      ;; required IFF the state is itself compound (declares :states)
 :states  {<fsm-keyword> <state-node>, ...}  ;; nested substates — makes this a compound state
 :meta    {<user-keys> ...}}                 ;; user-defined meta (NOT used for terminal marking — see §Final states)

All keys are optional except :initial (which is required when :states is present — see §Hierarchical compound states). Capability-gating: :always, :after, :tags, :spawn, :spawn-all, :states / :initial, and the :type :history pseudo-state node (below) are claimed-capability features per §Capability matrix — a port that doesn't claim a capability rejects the corresponding keys at registration time with :rf.error/machine-grammar-not-in-v1.

One sibling-of-a-substate node is not an ordinary state but a history pseudo-state{:type :history :deep? <bool> :default-target <target>} declared under a compound's :states. It is never occupied; it is a transition target that resolves to the compound's recorded (or default) configuration. Its grammar and constraints are specified in §History states; it declares none of the ordinary state-node keys above.

A state node MUST NOT declare both :spawn and :spawn-all — they are mutually exclusive at the same node (a node spawning a single child uses :spawn; a node spawning N parallel children uses :spawn-all). make-machine-handler rejects the combination at registration time as a malformed transition table.

:entry and :exit are single fns or single keyword references into the machine's :actions map — never vectors. To run multiple actions on entry, write a fn that calls them in order (or name a compound entry in the machine's :actions map; the named id is richer for tooling).

:spawn is declarative sugar that make-machine-handler desugars into entry/exit :rf.machine/spawn / :rf.machine/destroy fx at registration time; per-state at most one :spawn. See §Declarative :spawn for the spec-spec keys, desugaring rules, composition with explicit :entry / :exit, and the deliberate omissions vs xstate.

Transitions

A transition spec for :on may be:

;; minimal — just a target
{:on {:right-click-circle :editing}}

;; with guard and action
{:on {:right-click-circle
      {:target :editing
       :guard  :circle-exists?
       :action (fn [{[_ id radius] :event}]
                 {:data {:circle-id id :initial-radius radius :preview-radius radius}})}}}

;; multiple candidates with guards (first matching wins)
{:on {:submit
      [{:target :rate-limited :guard :over-limit?}
       {:target :validating   :guard :email-valid?}
       {:target :rejected}]}}                        ;; fallthrough

Transition slots:

slot shape when it runs
:target FSM-keyword (or :same-state for an external self-transition; omit for internal) discriminates the next state
:guard one fn or one keyword reference (resolves into the machine's :guards map) predicate; transition fires only if truthy
:action one fn or one keyword reference (resolves into the machine's :actions map) between exit and entry
:meta map tooling-visible, no runtime effect

:action is singular — one fn or one keyword reference. Multiple steps compose inside the fn body or as a named compound entry in the machine's :actions map.

Wildcard transitions

:on accepts the wildcard key :*, matching any event the state does not otherwise handle:

{:idle      {:on {:start :running
                  :*     :error}}        ;; any other event drops to :error

 :listening {:on {:msg/data {:action (fn [{ev :event}] {:data {:last ev}})}
                  :*        {:action (fn [{ev :event}] {:fx [[:log/unknown ev]]})}}}}

Precedence inside the standard transition lookup, at each level:

  1. Explicit event match at this level.
  2. :* wildcard at this level.

The wildcard fires after specific matches at the same level. Only if neither matches does the runtime walk up to the next level and try again — so :* at the leaf shadows an explicit match on the parent for the same event. The full leaf-up-to-root walk is canonically specified at §Transition resolution — deepest-wins with parent fallthrough; for a flat machine the path is one level deep and the two-step rule above is the whole story. If no level matches, the snapshot is unchanged and the runtime emits a single benign :rf.machine.event/unhandled-no-op trace (see §Transition resolution for the canonical name + the xstate-v5-parity rationale — an unhandled event is a no-op, not an error).

Self-transitions (external vs internal)

  • :target :same-stateexternal self-transition. :exit of source and :entry of target both fire.
  • Omit :target entirely — internal self-transition. The transition's :action runs; :exit and :entry do not.

Internal transitions are how to update :data without re-running entry/exit machinery.

Guards

A guard is (fn [{:keys [data event state meta]}] boolean) — a single context-map argument. One inline fn or one keyword reference into the machine's :guards map — never a compound data form.

data is the snapshot's :data slot (a plain map). event is the inbound event vector. state is the discrete FSM-keyword and meta is any user :meta declared on the snapshot — both available for introspection without an opt-in. The fn destructures whichever keys it needs; the runtime supplies the full map every time.

;; destructure the keys you need — `data` is the snapshot's :data slot
:guard (fn [{:keys [data event]}]
         (some? (:circle-id data)))

;; keyword reference — resolves to (get-in spec [:guards :circle-exists?])
:guard :circle-exists?

;; compound logic — write the fn
:guard (fn [{:keys [data event]}]
         (and (active? data event) (under-quota? data event)))

;; even better — declare a named compound in the machine's :guards map
(rf/make-machine-handler
  {:guards {:active-and-under-quota?
            (fn [{:keys [data event]}]
              (and (active? data event) (under-quota? data event)))}
   :states {... :on {... {:guard :active-and-under-quota?}}}})
;; the name carries semantic meaning that visualisers / AIs read.

Snapshot introspection — :state / :meta

Guards or actions that need to branch on the discrete FSM state name or on any user :meta simply destructure the keys from the context map:

:guard (fn [{:keys [data event state meta]}]
         (and (= :loading state)
              (under-quota? data)))

The context-map shape is uniform across every callback slot — :state and :meta are always present alongside :data and :event for :guard / :action / :entry / :exit. There is no opt-in flag and no separate 3-arity variant; the runtime delivers the full map and the user's destructure pattern decides what's bound.

Compound logic is expressed via function composition or as a named entry in the machine's :guards map — the name carries semantic content visualisers and AIs read. Resolution is machine-scoped per §Registration — the machine IS the event handler; unresolved references fail registration with :rf.error/machine-unresolved-guard.

Actions

An action is (fn [{:keys [data event state meta]}] effects) returning the {:data :fx} shape (or nil). Single context-map argument, same shape as guards. One inline fn or one keyword reference into the machine's :actions map — never a vector.

;; inline — destructure :data and the event from the context map
:action (fn [{[_ id radius] :event}]
          {:data {:circle-id id :initial-radius radius :preview-radius radius}})

;; keyword reference — resolves to (get-in spec [:actions :clear-form])
:action :clear-form

;; the body lives in the machine's :actions map:
(rf/make-machine-handler
  {:actions {:clear-form
             (fn [_]
               {:data {:circle-id nil :initial-radius nil :preview-radius nil}})}
   :states {... :on {... {:action :clear-form}}}})

Multiple steps in one action are fn composition, not a vector:

:action (fn [{:keys [data event] :as ctx}]
          (let [a (action-clear ctx)
                b (action-record-attempt ctx)]
            {:data (merge (:data a) (:data b))
             :fx   (into (:fx a []) (:fx b []))}))

If the composition is reused, name it in the machine's :actions map:

(rf/make-machine-handler
  {:actions {:clear-and-record (fn [{:keys [data event]}] ...)}
   :states {... :on {... {:action :clear-and-record}}}})

This is the design rule from above: imperative composition is fns, not data DSLs; named entries in the machine's :actions map add semantic content visualisers and AIs can read. Resolution is machine-scoped per §Registration — the machine IS the event handler; unresolved references fail registration with :rf.error/machine-unresolved-action. Cross-machine reuse: define a Clojure var and reference it from each machine's :actions map.

Schema validation

a machine spec MAY declare a top-level :schema key. The schema validates the machine's :data slot — the user-domain extended state. Mirrors how :schema rides on every other registration kind (reg-event-*, reg-cofx, reg-fx, reg-sub):

(rf/reg-machine :drawer/editor
  {:initial :idle
   :data    {:circles [] :undo [] :redo []}
   :schema  DrawerData                ;; validates :data
   :guards  {...}
   :actions {...}
   :states  {...}})

The schema's job is exactly the user-domain :data shape. The snapshot's :state slot is already validated at registration time (a transition targeting an unknown state fails registration); the snapshot's reserved :rf/* slots are framework-owned.

Validation timing — the macrostep boundary. :data is mutated at six sites (initial install, :on-spawn-actions, :entry actions, :exit actions, transition :action, :always / :after actions). The natural validation point is the macrostep commit — after the exit → transition-action → entry sequence settles and the runtime is about to write the new snapshot back to app-db. One validation per macrostep regardless of how many actions fired; the snapshot the framework would write is the value validated. This is the same lifecycle position as the existing :where :app-db check.

Initial-data installation. At machine bootstrap the initial :data is installed into the snapshot. The bootstrap cascade fires the initial state's :entry actions on the first event, then commits — the macrostep validator catches both the bootstrap typo (:data {:circles} declared but the schema requires :cirles) and any initial-:entry action that returns a violating :data.

Spawn-time validation. A spawned actor's initial :data is validated before the snapshot lands in app-db (rather than at the next macrostep commit). A failing spawn never installs — the actor never enters the runtime, and no parent state observes a half-installed child. The failure emits with :phase :spawn and :rollback? false (no commit to roll back).

Failure trace. The boundary reuses the existing :rf.error/schema-validation-failure op with a new :where value:

{:op   :rf.error/schema-validation-failure
 :tags {:where           :machine-data
        :failing-id      <machine-id>           ;; uniform error-emit alias
        :machine-id      <machine-id>           ;; domain-specific synonym
        :phase           :macrostep             ;; or :spawn
        :value           <failing-:data-map>    ;; redactable per Spec 010 §`:sensitive?`
        :received        <failing-:data-map>    ;; parallels :where :app-db
        :schema          <the registered schema verbatim>
        :explain         <validator's explainer output>
        :rollback?       true                   ;; false for :phase :spawn
        :recovery        :no-recovery
        :reason          "Machine <id> :data failed schema..."}}

Consumers route on :where — the existing Issues triage, Xray projections, schema-violation-row plumbing, and the per-step attachment bead all flow through one row shape with one new :where case.

Recovery — full-cascade rollback. Machine state lives in app-db; the cascade's :db-before / :db-after capture the pre/post snapshot of EVERY machine along with the rest of app-db. A failure at the :where :machine-data boundary rolls back the entire commit (same mechanism the :where :app-db boundary uses). The framework cannot surgically roll back just one machine's macrostep without affecting other state mutations the handler performed; full-cascade rollback is the only consistent option. For the Epoch panel: same blast-radius muting treatment as :where :app-db rollback.

Production builds. Per 010 §Production builds, the validation site is gated by re-frame.interop/debug-enabled? and DCEs to a no-op under :advanced + goog.DEBUG=false. The boundary is dev-only by default; apps needing production validation at system boundaries reach for the :rf.schema/at-boundary interceptor on the specific events that ingest untrusted machine :data (e.g. an SSR-hydrate that restores machine snapshots from the wire).

Cross-reference. Per 010 §Per-step recovery row 7, this boundary is row 7 of the per-step recovery table; the :where :machine-data value is the closed-set extension to Spec-Schemas §SchemaValidationTags. The two paragraphs at §Where snapshots live (schema composition) and in §What the Single Store gives us for free (the Schema-validation bullet) describe this surface as fact.

Trace events — guard evaluations and action runs

Every user-declared guard evaluation and every user-declared action invocation emits a public trace event under the reserved :rf.machine/* namespace (per 009 §The :rf.machine/* reserved namespace for trace events and):

  • :rf.machine/guard-evaluated — emitted from the unified evaluate-guard helper at every user-declared guard call site (:on, :after, :always, :spawn-all/join). :tags {:machine-id <id> :guard-id <kw-or-fn> :input {:data <data> :event <event-vec>} :outcome :pass | :fail | :threw :exception <Throwable on the throw path>}. The synthesised always-true returned by resolve-guard for a nil guard-ref is NOT a user-declared evaluation — no trace. First-fail short-circuit on compound guards is preserved: subsequent legs simply do not evaluate. Per a guard that throws emits one trace with :outcome :threw and :exception <Throwable> and is treated as :fail — the candidate walk continues evaluating siblings, mirroring the :rf/transition candidate semantic ("this candidate declined; try the next") rather than aborting the whole cascade.
  • :rf.machine/action-ran — emitted from run-action for every user-declared action invocation. :tags {:machine-id <id> :action-id <kw-or-fn> :phase :exit | :transition | :entry | :always | :after-action | :initial-entry | :destroy-exit :input {:data <data> :event <event-vec>} :outcome <return-value> | :ok | :rf.error/action-threw :exception <Throwable on the throw path>}. Success-with-nil-return collapses to :outcome :ok (action returned nil; the runtime treats it as the no-op {}). The throwing path emits one trace with :outcome :rf.error/action-threw and :exception <Throwable> before propagating the result/fail Result; the failure subsequently surfaces as :rf.error/machine-action-exception per §Errors. The synthesised (constantly nil) no-op for a nil action-ref is NOT user-declared — no trace. Per every emit carries :phase from the closed set above — :exit / :entry for cascade actions, :transition for an :on-driven action, :always for an eventless step's action, :after-action for an :after-timer-driven action, :initial-entry for the creation initial-entry cascade, :destroy-exit for the destroy-time exit cascade. Downstream consumers (Xray's epoch panel Handler section) group rows by :phase directly from the trace stream.

Both traces flow through the standard trace bus, so *handler-scope* auto-stamps :dispatch-id into :tags. Downstream cascade-correlation (Xray's :rf.xray/machine-transitions-for-focused-event sub, devtools epoch buffers, conformance fixtures) groups guard/action traces with the originating event without any explicit threading. The payload schemas are pinned in Spec-Schemas §MachineGuardEvaluatedTags and §MachineActionRanTags. The :sensitive? inheritance contract per §Privacy — :sensitive? inheritance on machine trace events applies to both — handler-scope metadata stamps the whole cascade, so :input :data and :input :event are scrubbed at the boundary alongside the surrounding :rf.machine/transition payload.

The structured transition cascade — :cascade on :rf.machine/transition

The per-action :rf.machine/action-ran stream above lets a tool reconstruct what ran, but only by re-walking the LCA geometry to know which state each action belonged to and in what cascade order. The macrostep's headline :rf.machine/transition trace therefore also carries a structured :cascade field — the engine emits, in a single trace, the ordered step sequence that explains how the transition reached its after-state (rf2-n9f4z). One trace, one place for tooling to read; this is the contract Xray's epoch panel renders (rf2-52u5n), and it removes the need for app-level :data :trail workarounds that previously made the cascade observable by hand.

:cascade is a vector of self-describing step maps in execution order, following the ordering §Entry/exit cascading along the LCA already defines — exit deepest-first → transition :action at the LCA → entry shallowest-first + initial-descent, then one step per :always/eventless microstep:

;; one cascade step (the structural shape the consumer reads)
{:kind   :exit | :action | :entry | :microstep   ;; the structural boundary
 :state  [:running :conditioning :heating]        ;; state-path exited/entered;
                                                   ;;   the transition's decl-path for :action
 :region :climate | nil                            ;; parallel region (nil for flat/compound)
 :action :enter-heating | nil                      ;; the action id that fired
                                                   ;;   (nil ⇒ this boundary declared no
                                                   ;;    :exit/:entry action — still recorded
                                                   ;;    so the configuration walk is complete)
 :data-delta {:trail [...]}                        ;; the :data keys THIS step's action
                                                   ;;   added/changed (empty {} when no action,
                                                   ;;   or the action wrote no :data)
 :source :recorded | :default}                     ;; ADDITIVE, history-only: present ONLY on
                                                   ;;   an :entry step produced by a :type :history
                                                   ;;   restore — :recorded (replayed the owning
                                                   ;;   compound's last-active config) or :default
                                                   ;;   (no recording yet; fell back to
                                                   ;;   :default-target / :initial). Matches the
                                                   ;;   :rf.machine.history/restored event's :source.
                                                   ;;   ABSENT on every non-history step.

;; a :microstep step additionally carries the eventless step's own nested cascade
{:kind            :microstep
 :region          :climate | nil
 :microstep-index 0
 :from            :asking
 :to              :winner
 :steps           [ exit/action/entry step maps for the eventless transition ]}

Key properties:

  • Complete configuration walk. Every state exited and entered is recorded — including boundaries with no declared :exit/:entry action (:action nil, empty :data-delta) — so the geometry is explainable without the spec. (An app-level :data :trail only captured action-bearing boundaries; the cascade is a superset.)
  • :kind is structural, not the action driver phase. It is the closed set :exit / :action / :entry / :microstep. The orthogonal driver phase (:transition / :always / :after-action / :initial-entry / :destroy-exit) is what the per-action :rf.machine/action-ran emit stamps under :phase; the two dimensions never smear (see the action-ran :phase set above).
  • :data-delta is the minimal per-step contribution — only the :data keys that step's action changed, never the whole (possibly large) :data map. This keeps the cascade small and side-steps a large-payload leak.
  • :source is an additive, history-only field. An :entry step produced by a :type :history restore carries :source :recorded (the owning compound's last-active configuration was replayed) or :source :default (no recording existed yet, so the leaf came from the history node's :default-target / the compound's :initial), matching the :rf.machine.history/restored event's :source (see §History states). The key is absent on every non-history step — a consumer treats its absence as "ordinary cascade entry".
  • Per-region structure for parallel machines. Each step carries its :region; the cascade is the per-region step sequences concatenated in region declaration order, so a consumer can group by :region (rf2-52u5n wants per-region detail). Flat / compound machines carry :region nil.
  • :always microsteps are explainable. Each eventless macrostep iteration appends a :microstep step carrying its own nested :steps — so "all the steps" = the entry/exit cascade plus the microstep stream, rather than a bare count. This composes with the per-microstep :rf.machine.microstep/transition stream (the latter stays the per-microstep marker; :cascade is the macrostep-level structured rollup).
  • Bootstrap composition. When one handler call both bootstraps the machine and processes a user event (the same call the :before/:after slots span), the initial-entry cascade's :entry steps prepend the event-driven steps, matching the macrostep the trace reports.

The privacy story is the same handler-scope stamp as :before / :after (see §Privacy — :sensitive? inheritance on machine trace events): a sensitive machine's whole :rf.machine/transition event — :cascade :data-deltas included — is stamped :sensitive? for the consumer to scrub at egress. The field is observability via trace/emit! (production-elided through the standard interop/debug-enabled? gate), never production app-db, so it adds no new elision concern beyond the existing trace payload.

:machine-guard / :machine-action handler-meta surfaces

The reg-machine macro walks the literal spec form at expansion time and writes per-(machine-id, id) entries into the registrar under the :machine-guard and :machine-action registry kinds — sibling to the closed :event / :sub / :fx / :cofx / :view / :frame / :route / :head / :error-projector / :flow kinds, per 001 §Registry model. Each entry carries:

(rf/handler-meta :machine-guard  [<machine-id> <guard-id>])
;; => {:rf/guard-id   <guard-id>
;;     :rf/machine-id <machine-id>
;;     :rf.handler/source  "(fn [{data :data}] ...)"
;;     :handler-fn    <the captured fn>
;;     :ns :line :file [:column]}        ;; per-element coord per Spec 001

(rf/handler-meta :machine-action [<machine-id> <action-id>])
;; => {:rf/action-id  <action-id>
;;     :rf/machine-id <machine-id>
;;     :rf.handler/source  "(fn [{data :data}] {:fx ...})"
;;     :handler-fn    <the captured fn>
;;     :ns :line :file [:column]}

The ids are 2-vectors [<machine-id> <id>] so a guard's id is naturally scoped to its declaring machine. :rf/guard-id and :rf/action-id are reserved markers (parallel to :rf/cofx-id per #2097) that let tools enumerating (rf/registrations :machine-guard) pivot on the marker rather than re-parsing the 2-vector id. :rf.handler/source carries the macro-time pr-str of the literal fn-form (parallel to the reg-event-* form-source capture per Spec 009 §:rf.handler/source). The source coordinates and source string both ride the co-located element entry (:guards {<id> {:fn .. :source-coords .. :source-code ..}} — see §Source-coord stamping), so jump-to-source on a guard/action chip leads straight to the fn literal in the user's source file.

Production-elision per Spec 009 §Production builds: the macro emission omits the co-located :source-coords / :source-code slots under :advanced + goog.DEBUG=false (the dev arm of the interop/debug-enabled? gate DCEs), and the runtime registrar writes no-op when the gate is false, so neither the pr-str fn-body bytes nor the :rf.handler/source keyword reach the production bundle (verified by the elision-probe co-located :source-code sentinels — see 009 §Production builds).

The reg-machine* plain-fn surface (per §reg-machine vs reg-machine*) bypasses the macro walker — programmatic registrations carry no co-located source under these kinds. Tools fall back to the call-site coords on the top-level (rf/handler-meta :event <machine-id>), identical to the existing source-coord stamping fallback.

The runtime semantics — guard resolution + action invocation — are unchanged. The :guards / :actions maps inside the machine spec remain the source of truth the runtime resolves through; co-locating :source-coords / :source-code alongside the :fn carries observability metadata only, and the runtime engine reads each entry's :fn (bare-fn and keyword-reference entries are also accepted, per §Source-coord stamping).

Consumers: Xray's Machine Inspector §Focused-transition lens renders guard / action fn-source inline under their declared id; re-frame-pair MCP surfaces source-jump for the same ids; future agents that inspect machine bodies read the form-strings from these surfaces.

Action effect map — {:data :fx}

Actions return:

{:data {<merged data updates>}        ;; the machine's own slice
 :fx   [[<fx-id> <args>] ...]}        ;; standard re-frame fx vector

Two keys. Symmetric with reg-event-fx's {:db :fx} — same shape, different scope. Both keys are optional. Returning nil means "no effects."

:data semantics: merge with the existing data map (last write wins on key collision). Explicit nil clears a key:

{:data {:circle-id nil :initial-radius nil :preview-radius nil}}

When N action slots fire in one transition (:exit:action:entry), :data updates merge in slot order; :fx vectors concatenate left-to-right.

:raise, :rf.machine/spawn, and :rf.machine/destroy are reserved fx-ids inside :fx

Not separate top-level keys. The machine handler walks :fx left-to-right and routes by fx-id:

{:fx [[:raise              [:event-1]]                                          ;; back into THIS machine, atomic, pre-commit
      [:raise              [:event-2]]
      [:rf.machine/spawn   {:machine-id :request/protocol
                            :system-id  :child   ;; address the child by name (its id is NOT recordable via :on-spawn — return dropped)
                            :start      [:begin]}]                              ;; child actor (see §Spawning)
      [:rf.machine/destroy actor-id]                                            ;; tear down a spawned actor
      [:dispatch           [:other-machine [:notify]]]                          ;; standard re-frame :dispatch
      [:http               {...}]]}                                             ;; any other registered fx

Routing rules (per §Drain semantics):

  • [:raise <event-vec>] — appended to the machine's local pre-commit raise-queue.
  • [:rf.machine/spawn <spawn-spec>] — a pure app-db write: installs the spawned actor's snapshot at [:rf/runtime :machines :snapshots <spawned-id>] (stamping its TYPE under :rf/machine-type) and tracks the id at [:rf/runtime :machines :spawned <parent-id> <invoke-id>]. It registers NO per-instance handler — the actor's liveness IS its snapshot (per §Liveness is derived from app-db). The runtime invokes the spec's :on-spawn advisory callback (return is dropped); if :start is present, an event is queued to the new actor (which lazy-resolves its just-installed snapshot on first dispatch).
  • [:rf.machine/destroy <actor-id>] — runs the actor's :exit action, then a pure app-db write dissociating its snapshot at [:rf/runtime :machines :snapshots <actor-id>] and its spawn-registry slot. It clears no registrar entry (a spawned actor has none). Symmetric counterpart to :rf.machine/spawn. Used directly by user actions and emitted by the desugaring of :spawn on state exit.
  • Any other [fx-id args] — forwarded to the standard do-fx for runtime processing.

:raise is machine-internal and unqualified, matching re-frame's existing reserved unqualified fx names (:dispatch, :dispatch-later). :rf.machine/spawn and :rf.machine/destroy are namespaced under the framework's :rf.<feature>/... convention so user code can register them globally as canonical actor-lifecycle fxs (per §Top-level boot-time spawn). They are listed in Conventions.md §Reserved fx-ids.

Strict encapsulation — actions only see their own data

A machine almost never needs to write app-db directly; it acts on its own state, raises to itself, spawns/messages other actors, or emits fx. The locked rule is strict encapsulation: actions and guards cannot see app-db at all — only {:state :data} plus the event vector.

Why this is locked. Strict encapsulation is one of the named consequences of Goal 2 — Frame state revertibility. If actions could read or write app-db outside [:rf/runtime :machines :snapshots <id>], machine logic would create state changes that don't show up in any machine snapshot and don't roll back when the surrounding machine snapshot does. The whole machine's state has to live inside the frame's persistent value to revert with it; encapsulation is what stops machines from leaking state into parts of the value that aren't theirs.

  • Action signature: (fn [{:keys [data event state meta]}] effects) — single context-map argument; user destructures the keys it needs.
  • Guard signature: (fn [{:keys [data event state meta]}] boolean) — single context-map argument, same shape as :action.
  • What the fn sees: the keys it destructures from the context map — :data (the snapshot's :data slot), :event (the inbound event vector), :state (the discrete FSM-keyword), :meta (any user :meta on the snapshot). Never app-db; never cofx.

The impure plumbing (reading the snapshot from app-db at [:rf/runtime :machines :snapshots <id>], writing :data back as a :db write, lowering :fx / :raise / :rf.machine/spawn into standard re-frame effects) lives in the handler boundary — the fn returned by make-machine-handler. Inside the boundary: pure. Outside: standard re-frame.

Cross-cutting reads via the event payload. A view that needs to pass the circle's current radius into the editor includes it in the dispatch:

(rf/dispatch [:drawer/editor [:right-click-circle id radius]])

Whoever fires the event has the data; they pass it. The machine never reaches outside its own :data.

Cross-cutting writes via :fx [[:dispatch ...]]. To touch a sibling slice in app-db, an action dispatches a named event:

:close-dialog
{:target :idle
 :action (fn [{:keys [data]}]
           {:fx   [[:dispatch [:drawer/apply-radius
                               (:circle-id data)
                               (:preview-radius data)]]]
            :data {:circle-id nil :initial-radius nil :preview-radius nil}})}

This forces every cross-encapsulation write to be a named, traced, reusable event rather than a quiet reach into someone else's data. Tracing shows the apply-radius event by name.

Hard-disallow :db. An action's effect map cannot contain :db. If one is present, the runtime emits the structured error :rf.error/machine-action-wrote-db and drops the :db key (the rest of the action's effects flow through). The error is registered as a category in 009 §Error contract.

State-keyword is not in the action's return shape either — only the transition's :target decides the next state. Actions cannot bypass the FSM.

Path conventions in machine bodies

every machine callback receives a SINGLE context-map argument; the keys present per slot are:

Slot Signature Ctx keys What it returns
:guard (fn [{:keys [data event state meta]}] boolean) :data :event :state :meta a boolean
:action (fn [{:keys [data event state meta]}] effects) :data :event :state :meta {:data ... :fx ...} (or nil)
:entry / :exit same as :action :data :event :state :meta {:data ... :fx ...} (or nil)
:on-spawn (fn [{:keys [data id]}] _)advisory :data :id ignored (return is dropped; runtime tracks the spawn-id at [:rf/runtime :machines :spawned <parent> <invoke-id>])
:on-done (fn [{:keys [data result]}] new-data) :data :result the parent's new :data map
:after delay-fn (fn [{:keys [snapshot]}] ms) :snapshot a positive-int millisecond delay
:spawn :data fn (fn [{:keys [snapshot event]}] data) :snapshot :event the child's initial data map

Why uniform context-map. Slot-specific positional signatures ((fn [data event]) for guards/actions, (fn [data id]) for :on-spawn, (fn [data result]) for :on-done, (fn [snapshot]) for :after, (fn [snapshot event]) for :spawn :data) would create a paste-from-:guard-into-:on-spawn trap: id silently bound to the event vector (or vice-versa) with no runtime signal — a class of bug that survives review because both args destructure the same way. The unified shape eliminates the trap structurally: every callback receives ONE map, the keys say what they carry, and future slot additions extend the key set without expanding the arity-permutation matrix.

Return shape — narrow by purpose. Three buckets per Mike-LOCKED design:

  • :guard → boolean (the transition's gate).
  • :action / :entry / :exit / :on-done / :spawn :data → a fresh :data map (or, for actions, a {:data :fx} effects map). The runtime patches :data back into the snapshot. Logical state (:loading / :loaded / :error) is reserved for declarative transition apparatus (:on / :always / :after); callbacks can ONLY update working memory (the :data bag) — they cannot nudge the machine into a state the spec didn't declare. Matches xstate's assign invariant.
  • :on-spawn / :after advisory cases — :on-spawn returns are dropped (the runtime tracks the spawn-id at [:rf/runtime :machines :spawned <parent> <invoke-id>]); :after delay-fn returns the ms value the timer scheduler consumes.

Snapshot-level escape hatch. If a callback NEEDS to touch :state / :meta / :errors / :status / :data plus something else in one atomic write, emit [:rf.machine/update-snapshot {:rf/machine-id <id> :rf/patch {:errors ... :status ...}}] from inside the callback's :fx vector — NOT a return-shape hidden contract. :rf/machine-id names the actor whose snapshot at [:rf/runtime :machines :snapshots <id>] is patched; :rf/patch is merged onto that snapshot, restricted to the permitted top-level keys above (:state / :meta / :errors / :status / :data). A :db key in the patch is the same hard-disallow as in an action's effect map (it surfaces :rf.error/machine-action-wrote-db and is dropped); merging into a destroyed / unknown actor is a no-op.

The runtime is responsible for unwrapping the snapshot before calling these fns and for patching the result back into the snapshot. User code never names [:data ...] paths inside the body; if a callback needs to read or write a field, it does so on the destructured data directly (e.g. (:pending data), (assoc data :pending id)).

The same principle holds for any data DSL the conformance corpus or a tooling layer interprets on top of the surface: a :set step inside a body operates on :data, so its path is data-relative. [:set [:pending] x] writes data.:pending = x. [:set [:data :pending] x] would write data.:data.:pending = x, which is virtually never what's wanted.

Registration — the machine IS the event handler

A machine is registered as one event handler via reg-event-fx whose body comes from make-machine-handler.

(rf/reg-event-fx :drawer/editor
  {:doc "Modal-edit flow."}
  [undoable]
  (rf/make-machine-handler
    {:initial :idle                                       ;; initial FSM-keyword
     :data    {:circle-id nil :initial-radius nil :preview-radius nil}
     :guards  {:circle-exists? (fn [{data :data}] (some? (:circle-id data)))}
     :actions {:clear-error    (fn [_] {:data {:error nil}})}
     :states  { ... }}))

The :guards and :actions maps declare the machine's named guard / action implementations. Inside :states, a transition's :guard :circle-exists? resolves against this machine's :guards map; :action :clear-error resolves against :actions. Each machine has its own guards/actions namespace — there is no global :machine-guard / :machine-action registry. Inline fns remain first-class (:guard (fn [...] ...) skips the lookup).

Reference resolution:

  • :guard :form-valid?(get-in spec [:guards :form-valid?]).
  • :guard (fn [...] ...) → inline fn, called directly.
  • :action :clear-error(get-in spec [:actions :clear-error]).
  • :action (fn [...] ...) → inline fn, called directly.
  • :on-spawn :observe-spawn (when :on-spawn appears as a keyword reference, e.g. inside a :spawn slot) → resolved against an optional :on-spawn-actions map at the spec root if present, then falling back to :actions. Inline fns work as for :action. The :on-spawn-actions map is intended for advisory spawn-observation callbacks (logging / instrumentation), distinct from transition-time :actions — their return is dropped, so they are NOT the parent's id-recording mechanism (use :system-id / the registry slot / :rf.machine/update-snapshot per §Recording the spawned id user-side); declaring the map is optional and the fallback to :actions keeps single-map machines simple.

make-machine-handler walks the transition table at construction time and verifies every keyword reference under a :guard or :action slot (in :on, :always, :entry, :exit) resolves to a key in the spec's :guards / :actions map. A miss fails registration with :rf.error/machine-unresolved-guard or :rf.error/machine-unresolved-action carrying :tags {:guard-id <id> :machine-id <id>} (or :action-id). This catches typos and undeclared references at registration time, not at runtime.

Cross-machine reuse via Clojure vars. When a guard or action is shared across machines, define it as a Clojure var and reference the var from each machine's :guards / :actions map:

(defn user-authenticated? [data _]
  (some? (:user-id data)))

(rf/reg-event-fx :auth/login {}
  (rf/make-machine-handler
    {:guards {:authenticated? user-authenticated?}
     ...}))

(rf/reg-event-fx :settings/page {}
  (rf/make-machine-handler
    {:guards {:authenticated? user-authenticated?}
     ...}))

No framework support beyond ordinary Clojure var resolution. Each machine names the shared fn locally; the id is the meaning at the call site.

Hot-reload. Re-evaluating (reg-event-fx :machine-id (make-machine-handler {:guards {...} :actions {...} ...})) re-registers the handler with new :guards / :actions impls. Mounted snapshots survive (only the handler changes); the next dispatch uses the new bodies. Standard hot-reload story.

What this gives:

  • One id, one registration. Reuses the :event registry kind. (registrations :event) enumerates every machine alongside every other event handler; (handler-meta :event :drawer/editor) carries :rf/machine? true so tooling can identify it. The snapshot's location in app-db is fixed and runtime-managed (see §Where snapshots live).
  • Standard dispatch. dispatch and dispatch-sync route to a machine the same way they route to any handler.
  • Hot-reload. Re-eval of the registration replaces the table; live snapshots pick up the new interpretation on their next event.
  • Reading the snapshot. Views read the snapshot via the framework-shipped :rf/machine sub or its sub-machine wrapper — @(rf/sub-machine :drawer/editor) yields {:state ... :data ...} (or nil if not yet initialised). See §Subscribing to machines via sub-machine.

Sub-events are how the machine receives its inputs:

(rf/dispatch [:drawer/editor [:right-click-circle id radius]])
(rf/dispatch [:drawer/editor [:close-dialog]])

The handler dispatches on the second-position keyword (:right-click-circle, :close-dialog); the rest of the inner vector is the event payload visible to actions and guards.

Extra-args fold

The dispatched outer vector MAY carry additional elements after the inner event:

[:machine-id [:inner-event-id & inner-args] & extra-args]

When the runtime drains an event with this shape, the extras are appended (folded) onto the inner event before it's interpreted, producing:

inner-event = [:inner-event-id <inner-args>... <extra-args>...]

This makes the standard fx-callback convention work without ceremony. Idiomatic uses:

  • HTTP callbacks. A handler emits :on-success [:machine-id [:inner-id]] — a 2-element template. The fx implementation does (rf/dispatch (conj on-success response)), which conj-onto-the-outer produces [:machine-id [:inner-id] response]. The runtime folds the trailing response into the inner event, so the machine handler sees [:inner-id response] and the action's [_ result] destructure receives result.

  • Promise / future resolution patterns. Same shape: the surrounding async layer captures a 2-element template, conj's the resolved value, dispatches.

  • Chained dispatches that carry payload. Any callsite that wants to "ship a value into the machine" can use the [:machine-id [:event-id] payload] form rather than constructing the inner vector manually.

The fold only applies when the outer event has length ≥ 3 AND the second element is itself a vector. Length-2 dispatches ([:machine-id [:inner-id]]) and the legacy single-arg form ([:machine-id]) are unaffected. The runtime resolves the outer-shape ambiguity by inspecting the second element's type — a vector second element means "sub-event, fold extras"; anything else means "use the whole vector as the inner event" (compatibility fallback).

make-machine-handler is a pure factory

The fn make-machine-handler returns is the event handler. Crucially, the factory itself:

  • Registers nothing. No reg-* side effects at construction time.
  • Closes over no global state. No (get-machine-by-id ...) lookups bound at construction.
  • Does not know its own event id. The handler's id is bound by the surrounding reg-event-fx (or by the [:rf.machine/spawn ...] fx for dynamic instances).

This is a real constraint on the implementation, not just a testing affordance — it's what makes the singleton vs spawned symmetry clean (the registration happens outside the factory in both cases) and what makes Level-2 testing (per §Testing) possible without a test frame.

reg-machine — public registration surface

Alongside the underlying reg-event-fx + make-machine-handler form (per §Registration — the machine IS the event handler), the framework ships reg-machine as the standard public registration entry point for machines. Both forms register the same thing — an event handler whose body interprets the transition table — and they reach the same registry slot. reg-machine is the surface that tools, examples, and CP-5-generated scaffolds default to.

(rf/reg-machine :auth.login/flow
  {:initial :idle
   :data    {:attempts 0 :error nil}
   :guards  {:under-retry-limit (fn [{data :data}] (< (:attempts data) 3))}
   :actions {:begin-submit       (fn [{[_ creds] :event}] {:fx [[:http {...}]]})
             :record-failure     (fn [{data :data}]      {:data {:attempts (inc (:attempts data))}})}
   :states  { ... }})

Surface signature. Two arities of two forms:

  • (rf/reg-machine machine-id machine)macro. Walks the literal spec form at expansion time and CO-LOCATES per-element source onto each guard / action / on-spawn-action entry, plus a reference-site :source-coords co-located onto each map node (state-node / transition map) inside the :states tree (per §Source-coord stamping). The macro emits (reg-machine* …) after stamping; the runtime call site is the plain-fn surface.
  • (rf/reg-machine* machine-id machine)plain fn. Equivalent to (reg-event-fx machine-id (make-machine-handler machine)) plus the registration-metadata stamp. No source-coord walking — the spec is opaque data at the call site.
  • (rf/defmachine name [doc] spec)macro (def-shape). Defines a Var holding the spec value with per-element source stamped at the definition site, for the def-then-register shape (defmachine m {…}) / (reg-machine :id m). Does not register. See §Value-registered machines.

Both forms live in re-frame.machines (the day8/re-frame2-machines artefact, per Conventions.md) and are re-exported under re-frame.core for both JVM and CLJS callers. See API.md §Machines for the canonical API table.

Both forms return machine-id per the family-wide reg-* return-value convention.

Registration-metadata stamp. Both forms record two keys on the registry slot's metadata map (per 001 §Metadata-map shape):

  • :rf/machine? true — the discriminator. (rf/machines) filters (registrations :event) by this flag (per §Querying machines). User-written event handlers do not set this key.
  • :rf/machine <spec> — the spec map passed to reg-machine. (rf/machine-meta id) reads this back; tools that walk the transition table (visualisers, conformance harnesses, CP-5-time scaffolders) consume the spec via this key. When the macro path stamps source, each :guards / :actions / :on-spawn-actions entry carries its co-located :source-coords / :source-code, and each :states-tree map node (state-node / transition map) carries its own reference-site :source-coords directly inside this spec map.

Source-coord stamping on the call site (:ns / :line / :column / :file) follows the standard rules from 001 §Source-coord stamping: the macro stamps; programmatic registration via reg-machine* does not. See §Source-coord stamping for the per-element index.

reg-machine is itself late-boundre-frame.core carries the macro and a stub fn that resolves the producer through the late-bind hook table at registration time. Apps that use reg-machine MUST add day8/re-frame2-machines to their deps and require re-frame.machines at app boot; without it, the lookup throws :rf.error/machines-artefact-missing.

reg-machine vs reg-machine*

The reg-machine convenience surface splits along Clojure's let / let*, fn / fn* idiom:

Form Shape Source-coord stamping Use case
(rf/reg-machine machine-id machine-spec) macro Yes when machine-spec is an inline literal — call-site coords on the registry slot, AND co-located per-element source on each guard / action / on-spawn-action entry + reference-site :source-coords co-located onto each :states-tree map node, walked from the literal spec form (per §Source-coord stamping). When machine-spec is a symbol / non-literal, only the call-site coords are stamped (per-element source comes from defmachine instead). Standard form. Inline literal → full capture; value-registered (symbol) → pair with defmachine (§Value-registered machines).
(rf/reg-machine* machine-id machine-spec) plain fn None — the call-site predates the registration; the spec is opaque data Code-gen pipelines that produce specs at runtime, REPL exploration, conformance harnesses that synthesise machines from EDN fixtures.
(rf/defmachine name [doc] spec) macro (def-shape) Yes — walks the inline literal spec at the definition site and co-locates per-element source + the reference-site :source-coords on each :states-tree map node onto the def'd value (per §Value-registered machines). Does not register — pair with (reg-machine id name). The def-then-register shape: (defmachine m {…}) then (reg-machine :id m) so a value-registered machine carries per-element source.

The reg-machine / defmachine macros live at the re-frame.core boundary; the plain-fn surface lives in re-frame.machines/reg-machine* and is exposed publicly under re-frame.core/reg-machine* for both JVM and CLJS programmatic callers. The inline reg-machine macro emits (reg-machine* …) after stamping; the runtime never reaches both surfaces independently.

Source-coord stamping

When the reg-machine macro receives a literal-map spec form, it walks the form at expansion time and CO-LOCATES per-element source onto each guard / action / on-spawn-action entry, plus a reference-site :source-coords onto each map node (state-node / transition map) inside the :states tree. Tools (re-frame-pair, re-frame-10x, IDE jump-to-source) read one place per element — (get-in (rf/machine-meta machine-id) [:guards <id> :source-coords]) for a named guard's coord, (get-in (rf/machine-meta machine-id) [:states <id> :source-coords]) for a state-node's coord, (get-in (rf/machine-meta machine-id) [:states <id> :on <event> :source-coords]) for a transition's coord.

rf2-npvsx + rf2-vqja2 (clean redesign). Prior to rf2-npvsx, each guard / action lived across THREE parallel hierarchies keyed by the same id — the :fn in the spec proper, the coord in a :rf.machine/source-coords side-index, and the pr-str source in a :rf.machine/handler-source side-index — forcing a consumer to cross-reference all three to assemble one element. Those side-indexes are GONE; the per-element payloads are co-located on each :guards / :actions / :on-spawn-actions entry. rf2-vqja2 finished the job for STATES: the reference-site coords no longer live in a flat :rf.machine/state-coords side-index that paralleled the :states tree — each :states-tree map node carries its own :source-coords directly, so a tool that has navigated to a state-node already has its coord in hand.

What gets stamped — co-located element entries

Each {<id> <fn>} entry under :guards / :actions / :on-spawn-actions becomes ONE cohesive map:

:guards  {:form-valid? {:fn            <compiled-fn>
                        :source-coords {:ns sym :file "path/login.cljs" :line 47 :column 13}
                        :source-code   "(fn [{data :data}] ...)"}}
:actions {:commit      {:fn            <compiled-fn>
                        :source-coords {:ns sym :file "path/login.cljs" :line 52 :column 13}
                        :source-code   "(fn [{data :data}] {:fx ...})"}}
  • :fn is ALWAYS present — it's the function the transition engine invokes (the engine reads (:fn entry); bare-fn and keyword-reference entries are also accepted for programmatic / indirection cases).
  • :source-coords / :source-code are DEBUG-only — present in dev, ABSENT in production (see §Production elision). In production each entry collapses to {:fn <compiled-fn>}.

What gets stamped — reference-site :states-tree coords

Each MAP node inside :states — a state-node, a transition map under :on / :always / :after, a :spawn map — carries its OWN :source-coords directly on the node:

:states {:idle {:on {:submit {:target :done
                              :guard  :form-valid?
                              :action (fn [_] {})
                              :source-coords {:ns sym :file "path/login.cljs" :line 80 :column 23}}}
                :source-coords {:ns sym :file "path/login.cljs" :line 78 :column 11}}
         :done {:source-coords {:ns sym :file "path/login.cljs" :line 84 :column 11}}}

This is the coord tools navigate to for "jump to call site" — where a transition / state-node lives in the spec tree, distinct from the per-element definition coord co-located on :guards / :actions. Because the coord lives ON the node, a tool that has already navigated from a snapshot to a state-node / transition map (the common gesture: clicking a state in the machine inspector, an edge in the diagram, a cascade row in the Epoch panel) reads (:source-coords node) with no separate index lookup.

Inline-fn / keyword slots (the exemption case)

Inline-fn and keyword slots inside :states:entry / :exit / :guard / :action / :on-spawn — hold a fn or keyword VALUE, not a map, so there is no node to hang a :source-coords key on. They are not stamped individually; a tool resolving such a slot reads the :source-coords off its nearest enclosing map node (the transition map for an inline :guard / :action; the state-node for an inline :entry / :exit). This is the same source line in practice, and it mirrors the keyword-reference rule: a keyword (:form-valid?) is a name, not a source form, so it too falls back to the enclosing map's coord.

Concretely for {:guards {:form-valid? (fn …)} :states {:idle {:on {:submit {:target :done :guard :form-valid? :action (fn [_] {})}}}}}:

Where Co-located coord? Why
:guards :form-valid? entry's :source-coords ✓ (when defined) fn literal carries reader meta
:states :idle state-node's :source-coords state-node map carries reader meta (CLJS)
:states :idle :on :submit transition map's :source-coords transition map carries reader meta (CLJS)
:states :idle :on :submit :guard — (resolves to the transition map) :form-valid? is a keyword — no node
:states :idle :on :submit :action — (resolves to the transition map) the inline fn is a value, not a map

Tools resolving a slot walk UP from its spec-path to the nearest enclosing map carrying :source-coords; for a "jump to call site" click on a state's :guard, they land on the enclosing transition's coord (the same source line).

Reading it back

;; Per-element definition coord + source — ONE lookup per element:
(get-in (rf/machine-meta :auth/login) [:guards :form-valid? :source-coords])
;; => {:ns ... :line ... :column ... :file ...}
(get-in (rf/machine-meta :auth/login) [:actions :commit :source-code])
;; => "(fn [{data :data}] {:fx ...})"

;; Reference-site coords — read directly off the map node:
(get-in (rf/machine-meta :auth/login) [:states :form :source-coords])
;; => {:ns ... :line ... :column ... :file ...}
(get-in (rf/machine-meta :auth/login) [:states :form :on :submit :source-coords])
;; => {:ns ... :line ... :column ... :file ...}

The top-level call-site coords (the position of the (rf/reg-machine ...) form itself) live on the registry slot as :ns / :line / :column / :file, queryable via (rf/handler-meta :event machine-id). These surfaces are independent: a tool highlighting the reg-machine declaration uses handler-meta; a tool highlighting a guard's definition reads its co-located :source-coords on the :guards entry; a tool highlighting a transition's source line reads the :source-coords on the transition map node.

Production elision

The macro emits an (if interop/debug-enabled? <dev> <prod>) branch. The DEV arm co-locates {:fn .. :source-coords .. :source-code ..} onto each element entry AND co-locates :source-coords onto each :states-tree map node; the PROD arm collapses each element entry to {:fn <fn>} and runs NO state-source splice, so prod state-nodes ship clean (just the user's authored {:on … :tags …}). Under :advanced + goog.DEBUG=false the closure compiler constant-folds the gate to false and DCEs the entire dev arm — every co-located :source-code string and every coord literal (per-element AND state-node / transition-map) are absent from the production bundle. The separable :fn is what lets the source bytes DCE while the live function ships; state-nodes have no extra runtime cost in prod because the splice never runs. Verified by the npm run test:elision sentinel grep (the co-located :source-code fn-body fragments, which ride the same dev arm as the state-source splice — there is no longer a :rf.machine/state-coords keyword to grep, and :source-coords is shared with the guards/actions arm).

JVM caveat

Clojure's LispReader only attaches :line / :column metadata to list forms (function calls, (fn …) bodies). Map and vector literals do NOT carry reader meta on JVM. So on JVM the walker captures co-located definition-site fn coords reliably (under :guards / :actions / :on-spawn-actions) but state-node and transition-map :source-coords are unavailable — those need the CLJS reader (cljs.tools.reader, which DOES decorate maps/vectors). Per Goal 1 (CLJS reference) the tooling-facing path is CLJS-side; the JVM caveat affects only JVM-side tooling that reads the node coords directly.

Programmatic registration

reg-machine* (the plain-fn surface) and any reg-machine macro call where the spec arg is a non-literal (a symbol, a let-bound expression) skip the per-element walk: there's no literal tree to walk at expansion time. The registered spec's :guards / :actions entries are bare fns (no co-located source) and its :states-tree map nodes carry no :source-coords in those cases — tools fall back to the call-site coords on handler-meta.

Value-registered machines — defmachine

The common app shape defines the spec as a top-level value and registers it by symbol:

(def door-machine {:initial :locked :guards {} :actions {} :states {}})
(rf/reg-machine :door/main door-machine)

Here the reg-machine macro sees only the symbol door-machine at its call site — not the inline literal — so the per-element walk above captures nothing and the registered spec's :guards / :actions entries are bare fns with no co-located source. The fix is defmachine, a def-replacement that walks the literal at the definition site and co-locates the source onto the def'd value, so it travels into reg-machine with the value:

(rf/defmachine door-machine
  "Optional docstring."
  {:initial :locked
   :guards  {:may-close? (fn [_] )}
   :actions {:count-open (fn [_] )
             :clear-hold (fn [_] )}
   :states  {}})

(rf/reg-machine :door/main door-machine)
;; (get-in (rf/machine-meta :door/main) [:actions :clear-hold :source-coords]) is now populated,
;; and (rf/handler-meta :machine-action [:door/main :clear-hold]) carries the fn source.

Normative rules:

  • defmachine performs exactly the same literal-spec walk as the inline reg-machine macro — the same co-located per-element :source-coords / :source-code on each guard / action / on-spawn-action entry, and the same reference-site :source-coords co-located onto each :states-tree map node — but applies them to the def'd value rather than at a registration call site. The co-location rides the same interop/debug-enabled? gate and DCEs identically under :advanced + goog.DEBUG=false.
  • defmachine is a drop-in for def: (defmachine name spec) or (defmachine name "doc" spec). It introduces a Var holding the (source-stamped) spec map. The optional docstring rides onto the Var's metadata.
  • defmachine does NOT register the machine — it only defines the stamped value. A subsequent (reg-machine id name) performs the registration; because the value already carries source, reg-machine (even seeing only the symbol) registers a spec that the runtime reads source off, writing the :machine-guard / :machine-action registrar handler-metas exactly as for an inline-registered machine.
  • Use defmachine for the def-then-register shape; use the reg-machine macro directly for inline-literal registration. Both yield identical per-element source; the choice is purely whether the spec value is named.

Without defmachine, the Epoch machine-cascade (and any tool reading cascade-row-coord / cascade-row-source-form) has no per-element source to render for value-registered machines, even though the inline-registered case works — this is the gap defmachine closes.

Design rule — data DSLs vs functions

Use data DSLs for deferred function calls (:fx [[fx-id args]]), named effects (:on-match [event]), declarative shape descriptions (schemas, hiccup), and static dependency declarations (:<-, :platforms).

Use functions for imperative composition and predicate composition.

Applied to machines: the transition table is a data DSL because it describes named transitions, target states, and references to registered fns — those are deferred function calls and static dependency declarations. Composing two actions ("clear the error and record the attempt") is imperative composition, so the action slot holds one fn (which can compose freely in code). Composing two predicates ("email valid and under retry limit") is predicate composition, so the guard slot holds one fn or one registered id — and a registered compound guard with a meaningful name (:active-and-under-quota?) carries more semantic information at the call site.

Inspectability bias

Inspectability bias. Machine tables should prefer named guards and actions declared in the machine's :guards / :actions maps over inline fns. The id (:under-quota?) carries semantic meaning that visualisers, AIs, and humans all read; an inline (fn [data ev] ...) is opaque to inspection. Inline fns are escape hatches for trivial logic (one-liners with no branching), not the default form.

The id is the meaning at the call site; the inline fn is opaque to readers. The machine-scoped resolution mechanics — how keyword references in transition slots resolve against the machine's :guards / :actions maps, and how cross-machine reuse via Clojure vars works — are specified once in §Registration — the machine IS the event handler.

This is a normative rule on top of the data-DSL-vs-fn rule: both forms are first-class at the grammar level (:guard and :action accept a fn or a keyword reference), but the default form is the named keyword reference. When a transition's logic is more than a single non-branching expression, name it in the machine's :guards / :actions map.

Why the bias:

  • Visualisers read ids, not fn bodies. A diagram exporter that renders the transition table can label an arrow with :under-quota? and have it mean something. An inline fn becomes "[fn]" — a hole in the rendered diagram.
  • AIs read ids, not fn bodies. When an AI reasons about a machine — generating tests, proposing changes, explaining behaviour — a keyword reference is a stable name it can resolve against the machine's :guards / :actions map (visible via (machine-meta <id>)). An inline fn is a closure with no public name.
  • Humans read ids, not fn bodies. A reviewer scanning a transition table sees :guard :under-quota? and knows what gates the transition; with :guard (fn [{data :data ev :event}] ...) they have to read the body to find out.
  • Tests read ids. Level-1 (machine-transition) and Level-2 tests can stub or assert against named guards/actions by id — re-define the spec's :guards / :actions entry with a deterministic stand-in. Inline fns can only be replaced by re-writing the entire transition table.
  • Conformance fixtures read ids. A fixture's expected :fx vector can name [:dispatch [:audit/login-ok]] against the action :record-success declared in the machine's :actions map; inline-fn equivalents are not addressable.

Inline fns remain acceptable for trivial bodies that don't add meaning by being named — e.g. :guard (fn [{data :data}] (some? (:circle-id data))) is fine; naming it as :has-circle? may add no information beyond what the body already shows. The test is whether the fn body is a single non-branching expression: yes → inline is OK; no → name it in :guards / :actions.

Cross-references: Construction-Prompts.md covers scaffolding guidance.

What 002 already gives us (recap)

002 §State machines are just event handlers commits the following at the foundation level:

  • The machine IS the event handler. A registered event handler whose body comes from make-machine-handler is the machine; machines register under the :event registry kind.
  • Three-way conceptual split: definition (data), instance (snapshot at the runtime-managed [:rf/runtime :machines :snapshots <id>]), frame (actor-system boundary).
  • Snapshot shape: {:state <fsm-keyword> :data <map>}. :state is the discrete FSM keyword (:idle, :editing, ...); :data is the extended state — the machine's own private memory.
  • Pure transition contract: (machine-transition definition snapshot event) → [next-snapshot effects].
  • Pure factory: (make-machine-handler spec) → fn. Returns a re-frame event-handler fn whose construction is a pure value transform of spec — its identity (the surrounding reg-event-fx id, or the [:rf.machine/spawn ...]-supplied id) is bound by the caller.
  • Definition shape: transition table is pure data; guards/actions referenced by id or supplied as fns; both forms are first-class.
  • Inspection: lifecycle/transition events emitted on the existing trace surface — discriminated by their :rf.machine.* :operation keyword (:rf.machine.lifecycle/created, :rf.machine/transition, :rf.machine/snapshot-updated, …). Machine-emitted dispatches carry :source :machine-action on the envelope (per rf2-c3990 — the actor-message path; :dispatch / :dispatch-later fx handlers stamp this when the parent envelope is :rf.machine/internal? true).
  • Composition: ordinary dispatch between machines, made deterministic by drain semantics.
  • Discipline: machines reuse the existing event registry, dispatch pipeline, and effect substrate; machine snapshots live as values in app-db.

This Spec describes everything else.

Drain semantics

The two mechanisms (:raise and :fx [:dispatch ...]) compose at four nested levels. Each level has a single deterministic rule. Implementations must produce these orderings exactly.

Level 1 — within a single action's effect map

An action returns {:data ... :fx [...]}. The two keys are independent:

  • :data — a single map; merged into the current data map (last write wins on key collision).
  • :fx — a vector of [fx-id args] pairs; processed in vector order. The machine handler's effect-map composer walks :fx and routes by fx-id:
  • [:raise <event-vec>] → appended to the local pre-commit raise-queue.
  • [:rf.machine/spawn <spawn-spec>] → installs the new actor's snapshot immediately (a pure app-db write — no per-instance handler registration; the actor's liveness IS its snapshot, per §Liveness is derived from app-db). Each spawn happens before the next :fx entry is processed; the spawned id is tracked at [:rf/runtime :machines :spawned <parent-id> <invoke-id>] and the spec's :on-spawn advisory callback fires for observation — its return is dropped; if :start is present, an event is queued to the new actor (which lazy-resolves its snapshot on first dispatch).
  • any other [fx-id args] → forwarded to the standard do-fx for runtime processing.

The relative order of :raise entries in :fx is the order they enter the local raise-queue. The relative order of non-raise fx entries is the order they reach do-fx.

This Level-1 walk is the machine-layer instance of the runtime-wide :fx ordering and atomicity guarantees:db (here, the lowered :data write) commits before any :fx entry, :fx entries process in source order, each entry's fx-handler returns before the next begins, and an fx-handler exception traces independently without halting subsequent entries. :raise is routed locally to the machine's pre-commit queue; :rf.machine/spawn and :rf.machine/destroy reach do-fx like any other registered fx (see §:raise, :rf.machine/spawn, and :rf.machine/destroy are reserved fx-ids inside :fx).

Level 2 — across the action slots in one transition

A transition that fires runs an ordered sequence of action slots. Flat machines see at most three slots, in this fixed order:

  1. :exit action of the source state (one fn or registered id).
  2. :action on the transition itself (one fn or registered id).
  3. :entry action of the target state (one fn or registered id).

Each slot is optional; absent slots are skipped.

Compound machines generalise the slot list along the LCA-cascade described in §Hierarchical compound states §Entry/exit cascading. Given source path A and target path B, with LCA L (longest common prefix):

  1. Exit cascade:exit actions of A's states from leaf back to (but not including) L. Deepest-first.
  2. Transition :action — fires once at the LCA boundary.
  3. Entry cascade:entry actions of B's states from (the level just below) L down to leaf. Shallowest-first.
  4. Initial cascade — if B's leaf is itself a compound state, descend its :initial chain; each cascaded state's :entry action fires shallowest-first as the path lengthens.

For a flat machine, A and B are length-1 paths, L is the root, and the four-step generalisation collapses to the three-slot exit → action → entry order above.

Self-transitions: if :target names the same state as the source, the transition is external — exit and entry fire. Omit :target entirely for internal — the transition's :action runs; no exit/entry, no cascade.

Composition across all action slots' returned effect maps:

  • :data updates merge in slot order — exit-cascade (deepest-first) → action → entry-cascade (shallowest-first) → initial-cascade. Last write wins on key collision.
  • :fx entries concatenate in slot order. Within the concatenated :fx, the Level-1 walk (:raise → local queue, :rf.machine/spawn / :rf.machine/destroy and the rest → do-fx) preserves order.

Level 3 — within a single machine event

When a machine receives an event:

  1. Resolve which transition fires (guards evaluated left-to-right; first match wins).
  2. Run the action group (Level 2).
  3. Drain the raise-queue: pop the front, dispatch it through the same machinery (Level 3 recursion), accumulate its :fx (including any :rf.machine/spawn / :rf.machine/destroy entries) into the same outer accumulator. Continue until the raise-queue is empty.
  4. Microstep loop — check :always: inspect the current state node's (and, for hierarchical compounds, every entered ancestor's deepest-first) :always vector. If a guarded entry matches (first-match-wins), apply that transition (run its :action, update the in-flight snapshot, accumulate its :fx) — then loop back to step 3 to drain any new :raise queue, then re-check :always. Continue until a fixed point is reached (no :always matches in the current state). See §Eventless :always transitions for the full microstep semantics.
  5. Commit the snapshot (state-keyword + merged data) to app-db at [:rf/runtime :machines :snapshots <id>], in a single :db write.
  6. Emit the accumulated :fx as the event handler's return value, which the standard re-frame interceptor pipeline's do-fx then processes.

The whole machine event — including all raised sub-events and all :always microsteps — appears as one logical step (one macrostep) to the outside. The trace shows it as one :rf.machine/transition with the raise-cascade and microstep count in its :tags. xstate/SCXML macrostep semantics: external observers (subs, other machines, tools) see only the post-commit settled snapshot.

Bounded by :raise-depth-limit (default 16, exceeding emits :rf.error/machine-raise-depth-exceeded) and :always-depth-limit (default 16, exceeding emits :rf.error/machine-always-depth-exceeded). Both limits halt the cascade with the snapshot uncommitted; the recovery is :no-recovery.

Level 4 — across the runtime

The router maintains a single per-frame queue. It is FIFO by default with one exception — machine-originated continuation events leap-frog to the front so a machine settles its macrostep before the next external event runs (SCXML-aligned).

  • Ordinary dispatched events go to the back. Events whose origin is user code, the UI, a timer/promise/websocket callback, an async-effect response, or any :fx [[:dispatch …]] emitted by a non-machine handler — go to the back of the queue. This is plain FIFO, even if the event targets a machine. The arrival order is the run order.
  • Machine-internal continuation events go to the FRONT. An event dispatched from a machine's own processing — its :action / :entry / :exit / transition handling, e.g. an action's :fx [[:dispatch …]] or an inter-machine dispatch — is inserted at the front of the queue, ahead of any already-queued external events. The effect: the machine drives its macrostep to quiescence before the next external event is processed, matching SCXML's "internal events run before external events" macrostep rule.
  • The cut is the dispatch's origin, not its target. An event leap-frogs iff it is a machine-originated continuation — dispatched during machine action / transition processing. An event that merely targets a machine but originates from user code, the UI, or a non-machine effect stays FIFO at the back. (The router tags machine-internal events at dispatch time; the marking mechanism is an implementation concern owned by — this spec states the observable order, not the mechanism.)
  • Each dequeue runs to completion before the next. A machine event's full Level-3 cascade (raised sub-events and snapshot commit) finishes before the next queue event is processed. Front-of-queue changes which event is dequeued next; it does not interleave cascades.
  • do-fx runs after the handler returns and before the next dequeue — so for a non-machine handler, :fx [[:dispatch :ev-X]] emitted during event Y lands at the back, after anything Y queued earlier and before the next dequeue. For a machine handler, the same :fx [[:dispatch …]] lands at the front; multiple machine-internal dispatches from one macrostep preserve their source order at the front (the first emitted is dequeued first).

:raise is unchanged — and is a different lever from front-of-queue. :raise is the in-memory, intra-macrostep, pre-commit mechanism: a raised event drains through the machine's local raise-queue inside the same handler invocation, depth-first, against the evolving in-flight snapshot, and never touches the router queue (per §:raise and Level 3 above). Front-of-queue is the separate lever for machine-originated events that do traverse the router queue (:fx [[:dispatch …]], inter-machine dispatches): these are real, separately-dequeued events that still settle ahead of external work. The two must not be blurred — :raise collapses chaining into one macrostep with no router round-trip; front-of-queue orders router-queue events so a machine's follow-on events run before external ones, each as its own dequeue.

Consistent with epoch-per-event (002 §Drain versus event). Front-of-queue changes order only, not granularity. Each leap-frogged machine-internal continuation is still a separately-dequeued event, so it is still its own epoch with its own six-domino cascade and its own trace — exactly as 002 §One epoch per dequeued event requires. :raise sub-events and :always microsteps remain inside the triggering event's epoch (they are not dequeued); a front-of-queue :fx [[:dispatch …]] is a fresh dequeue and a fresh epoch — it simply runs sooner.

Worked walkthrough

;; user code:
(rf/dispatch [:M [:start]])
(rf/dispatch [:other-thing])

;; runtime queue: [[:M [:start]] [:other-thing]]

;; --- dequeue [:M [:start]] -----------------------------------
;; suppose M's :start transition has:
;;   :action (fn [_] {:fx [[:raise [:input1]]
;;                           [:raise [:input2]]
;;                           [:dispatch :ev-A]]})
;; and :input1's transition has :action (fn [_] {:fx [[:dispatch :ev-B]]})
;; and :input2's transition has :action (fn [_] {:data {:n 1}})

;;   1. apply :start's action; walk :fx left-to-right:
;;        [:raise [:input1]]  → local raise-queue: [[:input1]]
;;        [:raise [:input2]]  → local raise-queue: [[:input1] [:input2]]
;;        [:dispatch :ev-A]   → outgoing fx: [[:dispatch :ev-A]]
;;
;;   2. drain local raise-queue:
;;      pop :input1 → run its transition; its :fx [[:dispatch :ev-B]]
;;        outgoing fx: [[:dispatch :ev-A] [:dispatch :ev-B]]
;;      pop :input2 → run its transition; data merges in
;;
;;   3. commit snapshot to app-db (one :db write at [:rf/runtime :machines :snapshots <id>])
;;
;;   4. emit outgoing fx → these are MACHINE-ORIGINATED dispatches, so
;;      do-fx inserts :ev-A, :ev-B at the FRONT of the queue (Level 4),
;;      ahead of the already-queued external [:other-thing], preserving
;;      their source order (:ev-A before :ev-B). The machine drives its
;;      follow-on events to quiescence before the next EXTERNAL event.
;;
;; runtime queue: [[:ev-A] [:ev-B] [:other-thing]]

;; --- dequeue [:ev-A] -----------------------------------------
;;   :ev-A is a plain (non-machine) handler; suppose it dispatches [:ev-C].
;;   :ev-C originates from a NON-machine handler → goes to the BACK (FIFO).
;;   runtime queue after: [[:ev-B] [:other-thing] [:ev-C]]

;; --- dequeue [:ev-B] -----------------------------------------
;;   ... runs; the remaining machine-originated continuation settles ...

;; --- dequeue [:other-thing] BEFORE :ev-C ---------------------
;;   The external [:other-thing] was leap-frogged by the machine's
;;   :ev-A / :ev-B, but it still precedes :ev-C: it was queued (from user
;;   code) before :ev-C (a non-machine back-of-queue dispatch). FIFO holds
;;   among non-machine events; only machine-internal continuations jump.

;; --- dequeue [:ev-C] -----------------------------------------
;;   last. Each dequeued event above — machine-originated or not — is its
;;   own epoch (per 002 §Drain versus event); front-of-queue changed only
;;   the order, not the epoch granularity.

;; --- dequeue [:ev-C] -----------------------------------------
;;   ...

Why these rules

  • FIFO at the runtime layer, with machine-internal events at the front — external events keep actor-mailbox FIFO semantics, identical to the trace's :dispatched-at ordering. The single exception is machine-originated continuation events (Level 4 above), which leap-frog to the front so a machine completes its macrostep to quiescence before the next external event — SCXML's "internal before external" rule. The cut is the dispatch's origin (machine processing), not its target, so external dispatches stay predictably FIFO.
  • Depth-first for :raise — within a machine, transition-chaining (a → b → c) is the natural unit of work; collapsing it into one externally-observable step matches how authors think about FSMs.
  • Action / transition / event composition is left-to-right, in-spec-order — readers of the transition table can compute the effect order by eye. No "actions can be reordered for optimisation"; the order in the source is the order at runtime.
  • Snapshot commit is atomic per machine event — sub-events raised within a machine see the evolving data through the local raise-cascade, but external observers (subs, other machines, tools) only see the post-commit snapshot. This prevents partial-snapshot observation.
  • Bounded raise-depth — protects against infinite :raise loops; emits a structured error.

These rules belong in the conformance corpus as fixtures that exercise each rule.

Drain semantics gotchas

The four-level drain has a small number of recurring implementation mistakes. Each is observable as a deviation from the rules above; each has a single fix.

  • Implementing :raise as a runtime-FIFO append rather than a local pre-commit queue. What goes wrong: the raised event lands at the back of the global router queue, behind other events queued in this turn — so external observers can interleave between the raise and its handling. Instead: keep a per-machine-event raise-queue inside the handler invocation; drain it depth-first before committing the snapshot, never via the runtime router.
  • Committing the snapshot before draining the raise queue. What goes wrong: sub-events in the cascade observe their own partial snapshot (the post-action commit), not the evolving in-flight one — so a chained raise can re-fire a transition mid-cascade. Instead: the snapshot is committed after the raise queue is drained (Level 3 step 4), exactly once, atomically.
  • Conflating :fx [:dispatch <self-id>] with :raise. They have different semantics on two axes — commit timing and macrostep membership. :raise runs before commit, depth-first, in the same logical step (one macrostep, one epoch, no router round-trip), against the evolving in-flight snapshot. :dispatch to self is a separate dequeued event (its own epoch) that round-trips through the router queue and runs against the post-commit snapshot. Because the dispatch originates from machine processing, it leap-frogs to the front of the queue (Level 4 above) — it runs before the next external event but after the current macrostep commits, not inside it. Instead: use :raise for transition-chaining intended to settle inside one externally-observable macrostep; use [:dispatch [<self-id> ...]] only when you genuinely want a fresh post-commit epoch — front-of-queue means it still runs ahead of external work, but it is a distinct step, not part of this one.
  • Not bounding raise-depth. What goes wrong: a buggy a → raise b → raise a → ... cycle hangs the runtime. Instead: enforce the default depth-16 limit and emit :rf.error/machine-raise-depth-exceeded when it's hit; halt the cascade and surface the path.
  • Treating "self-transition with :target" as internal. It is external:exit of source and :entry of target both fire (because the transition crosses the state-node boundary, even though source and target are the same keyword). Instead: use :target :same-state only when you want exit/entry to fire.
  • Treating "transition without :target" as external. It is internal — neither :exit nor :entry fires; only the transition's :action runs. Instead: omit :target only when you want a pure data update with no exit/entry machinery; if you want exit/entry, name the target.
  • Forgetting to cascade :initial when entering a compound state via vector target. What goes wrong: a transition with :target [:authenticated] (a compound state) lands the snapshot at the compound itself instead of descending — the snapshot's :state is [:authenticated] rather than [:authenticated :dashboard], and downstream code that walks to a leaf gets confused. Instead: every vector target whose final segment names a compound state continues to cascade the compound's :initial chain until it hits a leaf; the snapshot's :state is always a leaf path. Each cascaded state's :entry fires shallowest-first.
  • Resolving keyword :target against the runtime's current path instead of the declaring state. What goes wrong: a transition {:target :browsing} declared on a parent state is resolved as "the current state's sibling" rather than "this declaration's parent's child." Across deeply-nested machines the two diverge. Instead: keyword targets are statically resolved against the parent of the state-node that owns the :on map; the resolution is a property of the transition table, not of the snapshot. When in doubt, use the vector form — it is unambiguous.
  • Referencing an undeclared guard or action id. What goes wrong: a transition slot :guard :form-valid? (or :action :clear-error) names a keyword that does not appear in the machine's :guards (or :actions) map — a typo, a stale reference left over after a rename, or a copy-paste from another machine's spec. Instead: make-machine-handler walks every :guard / :action slot at construction time (in :on, :always, :entry, :exit) and verifies each keyword reference resolves against the machine-local :guards / :actions map. Misses surface as :rf.error/machine-unresolved-guard or :rf.error/machine-unresolved-action at registration time, with :tags {:guard-id <id> :machine-id <id>} (or :action-id). The error fires before any snapshot is created — caught at registration, not at runtime.
  • Unbounded :always cycle. What goes wrong: :a has :always {:guard :p? :target :b} and :b has :always {:guard :q? :target :a}; both guards remain true and the microstep loop never reaches a fixed point. The handler hangs the runtime. Instead: enforce the default depth-16 limit on the microstep loop and emit :rf.error/machine-always-depth-exceeded when it's hit; halt the cascade with the snapshot uncommitted, and surface the visited path. Gotcha is the same shape as the :raise cycle gotcha above — a separate counter, the same recovery pattern.
  • :always self-loop accepted at registration. What goes wrong: a state declares {:always [{:guard :ok? :target :same-state}]} (or {:target <itself>} with the same guard reference). The first microstep matches; the next microstep matches again on the same state and same guard; the loop runs to depth-limit and aborts. The author meant a no-op or got the topology wrong. Instead: make-machine-handler rejects any :always entry whose :target resolves to the declaring state itself with the same :guard reference (or no guard) at registration time, surfacing :rf.error/machine-always-self-loop with :tags {:state <state-keyword> :machine-id <id>}. The error fires before any snapshot is created — caught at registration, not at runtime. (A self-targeting :always with a different guard, used as a re-entry on a changed condition, is permitted; only the same-guard case is rejected.)
  • Forgetting to advance the :after epoch on state entry. What goes wrong: a machine handler schedules a fresh :after timer at state entry but reuses the prior visit's epoch counter — so an in-flight timer from the previous visit fires with a matching epoch and triggers an unintended transition long after the user moved on. Symptom: "a timer fired that I thought was cancelled." Instead: each :after-bearing node's per-path :rf/after-epoch entry advances on every entry to that node (per §Delayed :after transitions §Epoch-based stale detection); each scheduled timer carries its node's post-increment epoch and decl-path; the receiving handler validates both before firing. There is no cancellation — staleness is the cancellation mechanism.
  • Scheduling :after timers under SSR. What goes wrong: a machine entered server-side schedules a :dispatch-later for a 5-second timeout; the SSR render captures the timer-handle but the request-frame is destroyed before it fires; the timer leaks (or fires against a destroyed frame). Symptom: stray events on the server, or hydration mismatches because the server's snapshot includes "scheduled timer" state the client doesn't share. Instead: :after no-ops in SSR mode (per §SSR mode and 011-SSR §:after is no-op under SSR); the entry action skips timer scheduling on the server, and the client schedules them after hydration.

These gotchas are also worth fixturising in the conformance corpus — each one is a single-fixture assertion against a specific deviation.

Hierarchical compound states

A state node may itself contain a :states map — making it a compound state with its own substates. The grammar recurses: a substate may be a leaf (no :states) or another compound (has :states, must declare :initial). This extends the flat grammar additively; flat machines stay flat.

Why compound states exist. They factor common transitions out to a parent, so every authenticated descendant inherits :logout without restating it. The runtime walks from the active leaf up to root looking for a matching transition — this is the deepest-wins with parent fallthrough rule documented below.

Snapshot shape with hierarchy

For a compound machine, the snapshot's :state is a vector path from root to the active leaf:

{:state [:authenticated :cart :browsing]
 :data  {...}}

A flat machine's :state remains a single keyword (:idle); see §Snapshot shape for the dual form. A single-keyword :K read against a hierarchical definition is treated as the path [:K] (which must name a leaf at the root level).

Initial-state cascading

Every compound state-node MUST declare :initial — the substate to enter when control reaches the compound state without a deeper target. Entering a compound state cascades down its :initial chain until it reaches a leaf:

{:initial :authenticated
 :states
 {:authenticated
  {:initial :dashboard            ;; required because :authenticated is compound
   :states {:dashboard {...}
            :cart      {:initial  :browsing
                        :states {:browsing  {...}
                                 :paying    {...}
                                 :confirmed {...}}}}}}}

Targeting [:authenticated] lands the snapshot at [:authenticated :dashboard]; targeting [:authenticated :cart] lands at [:authenticated :cart :browsing]. Each cascaded state's :entry action fires in shallowest-first order as the path lengthens (see §Entry/exit cascading).

A compound state without :initial is a registration error — emits :rf.error/machine-compound-state-missing-initial at registration time and, in the CLJS reference with schema validation enabled, fails the registration.

Initial-state :entry fires on machine creation (start)

When a machine first comes into existence — a singleton on its first dispatched event, or a spawned actor on :rf.machine/spawn — the initial-state cascade's :entry actions fire once, shallowest-first, as part of bringing the machine to life. For a flat machine that means the single initial state's :entry runs; for a compound machine with a multi-level :initial chain, every state along the chain runs its :entry in shallowest-first order.

{:initial :outer
 :states  {:outer {:entry   :enter-outer        ;; fires on start
                   :initial :mid
                   :states  {:mid  {:entry   :enter-mid     ;; fires on start
                                    :initial :leaf
                                    :states  {:leaf {:entry :enter-leaf}}}}}}}
;; initial-entry log: [:enter-outer :enter-mid :enter-leaf]

The initial-entry cascade composes with all the slots the entry cascade carries — :spawn, :spawn-all, :after on any node along the initial chain emit their corresponding fx (:rf.machine/spawn, :rf.machine/spawn-all-init, :after-schedule) at creation time. So a :requesting initial state that declares :entry :fire-request AND :spawn {:machine-id :rf.http/managed ...} has the entry action run AND the child machine spawned, before the actor's first user-routed event arrives.

When creation happens — eager start vs lazy first event

Creation is a side-effect of the machine handler running, and the handler only runs when something is dispatched to the machine. So there are exactly two ways the initial-entry cascade fires — a per-machine choice, both kept:

  • Eager start — the surrounding program deliberately dispatches the synthetic [:machine-id [:rf.machine/start]] kick (xstate parity with createActor(m).start()) to bring the machine alive now — typically when the initial state has work to do at birth (arm an :after timer, run an :entry action, wire a subscription). The kick runs the initial-entry cascade then STOPS.
  • Lazy — no kick; the initial-entry cascade folds into the machine's first real event (the handler finds no snapshot, runs initial-entry, then processes the event in the same macrostep). Used when the initial state is quiet (nothing to set up at birth).

The start marker is a pure init-kick (no self-trigger). When the dispatched trigger is the reserved :rf.machine/start marker, the runtime runs the initial-entry cascade and stops — it does not re-feed the marker into the transition step as a normal event. This matches xstate's xstate.init, which enters the initial state and runs its :entry actions but is not itself re-processed as a user event. Consequence: an eager start never produces a :before == after self-transition row, and never trips a :* wildcard on the initial state (a creation kick can never reach an :on map). The birth is signalled instead by a dedicated :rf.machine/started trace (see §The :rf.machine/started trace).

For singleton machines the lazy-path initial-entry fx flow out as part of the first event's handler return value (the initial-entry cascade and the first event's transition cascade share the same :fx accumulator); the eager-start path emits only the initial-entry fx (it stops before any transition step). For spawned actors creation fires when the runtime dispatches the actor's first event — the synthetic [:rf.machine.spawn/spawned] per §Synthetic [:rf.machine.spawn/spawned] on spawn, or the user-supplied :start per §Spawn-spec keys.

Error semantics. A throw inside any initial-:entry action halts creation identically to a throw inside any other entry cascade: the snapshot does NOT commit, no :fx flow, and a single :rf.error/machine-action-exception trace fires (per §Errors). The pre-creation state — no snapshot at [:rf/runtime :machines :snapshots <id>] — is preserved. This holds on both the eager-start and lazy paths: an initial-:entry action that throws raises on the boot cascade itself, before any user event is processed.

The canonical shape for "do work on machine spawn" is :entry :fire-request on the initial state. Generic child machines MAY also declare :on :rf.machine.spawn/spawned :action :fire-request (the synthetic event the runtime dispatches when the spawn args carry no :start); this resolves as a no-op transition through the standard :on lookup. New code prefers :entry.

Synthetic creation marker — [:rf.machine/start]

The reserved :rf.machine/start marker has two roles, both :entry-only (it NEVER reaches an :on map):

  1. The eager creation kick. Dispatched as [:machine-id [:rf.machine/start]] it brings a machine alive now (see §When creation happens). As a pure init-kick it runs the initial-entry cascade then stops.
  2. The cascade :event placeholder. When the initial-entry cascade fires (on either the eager or lazy path), the runtime threads the placeholder event vector [:rf.machine/start] through the action machinery under the context-map's :event key — needed because action fns receive (fn [{:keys [data event state meta]}] effects) and there is no user-dispatched event to thread on the lazy first-event path's boot.

Most :entry actions ignore the event argument (it's the data argument they read from). Authors writing introspection-capable :entry actions — actions that read the event vector to decide what to do — observe [:rf.machine/start] on the boot call, distinguishable from any user-dispatched event by the reserved :rf.machine/* namespace.

;; Action reads the event head to log what triggered the entry.
(rf/reg-machine :auth-flow
  {:initial :requesting
   :states {:requesting {:entry :log-entry}}
   :actions {:log-entry
             (fn [{:keys [event]}]
               (let [trigger (first event)]
                 (println "entered :requesting via" trigger))
               {})}})

;; On creation, prints: "entered :requesting via :rf.machine/start"
;; On a later user-driven entry via `[:reset]`, prints: "entered :requesting via :reset"

The vector is purely synthetic: not registered as an event, never reaches a handler's :on map (the initial-entry cascade is :entry-only — there is no :on :rf.machine/start resolution; as the eager kick it is a pure init that stops, and as the placeholder it exists solely so the :entry action's event-argument is non-nil). The name is reserved under the :rf.machine/* namespace (per Conventions §Reserved namespaces); user code MUST NOT register a handler for this name. User code MAY dispatch [:machine-id [:rf.machine/start]] deliberately as the eager creation kick; it MUST NOT rely on any other interpretation of the marker.

Renamed from :rf.machine/bootstrap. Pre-alpha, no back-compat shim — the old keyword is gone. The rename gains xstate parity (createActor(m).start()) and pairs cleanly with the past-tense :rf.machine/started trace below.

The :rf.machine/started trace

Whenever a machine runs its initial-entry cascade — the single creation site, fired on both the eager-start and lazy paths — the runtime emits one :rf.machine/started trace. This is the canonical signal that a machine came to life; tooling (Xray's Epoch panel) renders it as a [START] badge. Its :tags:

Tag Value
:machine-id the machine / actor id
:frame the frame the instance lives in
:state the installed initial logical state (xstate value)
:data the installed initial extended state (xstate context)
:cause a :rf.machine.start/cause enum — how it started (below)

:rf.machine.start/cause{:explicit :lazy :spawned}:

  • :explicit — singleton creation where the dispatched trigger was the :rf.machine/start marker (a deliberate eager kick; the handler found no snapshot).
  • :lazy — singleton creation folded into a real first event (the handler found no snapshot; the trigger was an ordinary event). Flags that something dispatched to the machine before it was explicitly started — a possible ordering smell tooling can surface.
  • :spawned — the snapshot was pre-seeded :rf/bootstrap-pending? by a spawn fx; initial-entry ran on the actor's first dispatch.

The trace is emitted only when the initial-entry cascade actually runs: a throwing initial-:entry short-circuits to :rf.error/machine-action-exception instead (no :rf.machine/started), and a redundant [:rf.machine/start] on an already-alive machine runs no cascade and emits nothing.

Restoration paths emit NO :rf.machine/started. SSR hydration, restore-epoch / epoch replay, and reset-frame-db install a snapshot verbatim — the snapshot is the state (per §Restore semantics). Such a snapshot is present and not :rf/bootstrap-pending?, so the runtime runs no initial-entry cascade and emits no :rf.machine/started. A restored machine was not born here; it was reinstated. (This falls out for free from the missing-or-pending creation predicate; no special-casing of restoration is needed.)

Target resolution — vector vs keyword

A transition's :target admits two forms:

  • Vector form — absolute path from root. :target [:authenticated :cart] always means the named root-level state's named substate, regardless of where the transition is declared. Vector targets are unambiguous and the recommended form for cross-level transitions.
  • Keyword form — relative to the state where the transition is declared. :target :review resolves to a sibling of the current declaring state. The runtime resolves the keyword against the declaring state's parent's :states map. Note: "declaring state" is the state-node owning the :on map — not the state the machine happens to be in at runtime. This is a static resolution rule, evaluable from the transition table alone without consulting the snapshot.

Existing flat-machine targets (:target :editing) are keyword form, root-relative — unchanged from before, because in a flat machine the declaring state's parent is the root.

A target naming a compound state implicitly cascades through its :initial chain (see above). To target a specific leaf inside a compound, use the vector form: :target [:cart :paying].

Entry/exit cascading along the LCA

When the snapshot transitions from path A to path B, the runtime walks both paths and computes the LCA (longest common prefix). Three boundaries fire, in this order:

  1. Exit cascade. Walk A from leaf back toward LCA, firing each state's :exit action — deepest-first. Stop at LCA exclusive (LCA itself does not exit; we are not leaving it).
  2. Transition :action. Runs once at the LCA boundary, between exit and entry.
  3. Entry cascade. Walk B from (the level just below) LCA down to leaf, firing each state's :entry action — shallowest-first. If B's leaf is itself a compound state, continue cascading via its :initial chain; the cascaded states' :entry actions fire as the path extends.

This is a generalisation of the flat exit → action → entry rule (where path length is 1 and LCA is the root).

Transition resolution — deepest-wins with parent fallthrough

To resolve an event, the runtime walks the active path from leaf up to root, looking for the first state-node whose :on map handles the event. The first match wins:

  1. Leaf state's :on — explicit match.
  2. Leaf state's :on:* wildcard.
  3. Parent state's :on — explicit match.
  4. Parent state's :on:* wildcard.
  5. ... continue walking up ...
  6. Top-level (root) :on — explicit match.
  7. Top-level :on:* wildcard.

If no level matches an unknown user event, the snapshot is unchanged and the runtime emits the benign :rf.machine.event/unhandled-no-op trace (op-type :rf.machine, info-grade — see 009 §:op-type vocabulary). xstate-v5 parity (do not "fix" this back to an error): xstate v5 removed the v4 strict flag — an event with no transition in the current state is simply ignored, no warning, no throw. re-frame2 adopts the same runtime semantics: an unhandled event is a no-op. Unlike xstate, which emits nothing, re-frame2 keeps the benign :rf.machine.event/unhandled-no-op observability trace so a debugger can report that an event arrived and was ignored — benign is not invisible. To "fail loudly on unknown" (the xstate-v5 idiom), declare a :* wildcard whose action throws; that is a real :rf.error/machine-action-exception, not an unhandled-event no-op. This name is canonical; older drafts emitted :rf.error/machine-unhandled-event (and earlier :rf.warning/machine-unhandled-event / :rf.machine.event/unhandled) — all superseded.

A no-op is single-signalled. The :rf.machine.event/unhandled-no-op is the sole signal for an unhandled / guard-blocked event — the macrostep does not additionally emit a no-change :rf.machine/transition (one whose :before == :after, :microsteps 0, :cascade []). A no-change transition trace carries no information beyond the no-op signal and contradicts it (it borrows external-self-transition {X} → {X} vocabulary that implies the :exit/:entry firing that did not happen). The runtime suppresses the headline :rf.machine/transition emit whenever the macrostep changed nothing — :before == :after, an empty :cascade, and zero :always microsteps. This holds uniformly for an unhandled event and a guard-blocked event. An eager [:rf.machine/start] kick never reaches this site at all: it is a pure init-kick — it runs the initial-entry cascade then STOPS, never re-fed into the transition step — so it emits no :rf.machine/transition (the birth is signalled by :rf.machine/started, per §The :rf.machine/started trace), and a redundant [:rf.machine/start] on an already-alive machine likewise short-circuits and emits nothing. A machine's creation therefore never produces a :before == after self-transition row. (On the lazy path the macrostep is the real first event's transition — installing the initial state via a non-empty initial-descent :cascade — which is not a no-op and emits its :rf.machine/transition normally.) An internal self-transition (an action ran, no :target, :before == :after) likewise carries an :action cascade step and is never a no-op.

Reserved-:rf/* lifecycle carve-out. The no-op classifies an unknown user event a machine author may have forgotten to handle — it is NOT emitted for framework lifecycle traffic whose event-id lives in the reserved :rf/* root namespace (per Conventions §Reserved namespaces). The synthetic creation marker [:rf.machine/start] (per §Synthetic creation marker), the spawn kick-off [:rf.machine.spawn/spawned] (per §Spawn lifecycle — ordering), and the stories runtime's lifecycle / assertion pings (:rf.story.lifecycle/*, :rf.assert/*) are framework init, not events the author missed — a machine that declines them simply has no clause for them. (The eager :rf.machine/start kick is a pure init-kick that stops before the transition step, so it does not reach the no-op site as a trigger at all; the carve-out subsumes the marker only in its cascade-threaded-:event-placeholder role.) This aligns with xstate: xstate's own init (xstate.init) runs the initial-entry and is NOT reported as an unhandled event — only unknown user events are silently ignored. Distinguishing re-frame2's creation / spawn-kickoff makes us MORE like xstate, not a v5-parity violation. The carve-out is purely about labelling, not severity — nothing throws either way; it is a conscious refinement that restores the semantic distinction (severity stays benign, as in the rf2-ugdas downgrade) without reinstating any error advisory.

The deepest-wins rule means a child state can override a parent's transition for the same event by declaring its own. Combined with parent fallthrough, this is how hierarchy factors common behaviour to the parent (every authenticated descendant inherits :logout) while still allowing local override.

Worked example — auth flow

{:initial :unauthenticated
 :states
 {:unauthenticated
  {:on {:login [:authenticated]}}        ;; vector :target — absolute from root

  :authenticated
  {:initial :dashboard
   :on      {:logout [:unauthenticated]} ;; common — every authenticated descendant inherits
   :states
   {:dashboard
    {:on {:open-settings :settings        ;; keyword :target — sibling of :dashboard
          :open-cart     :cart}}
    :settings
    {:on {:close :dashboard}}
    :cart
    {:initial :browsing
     :on      {:close :dashboard}
     :states
     {:browsing  {:on {:checkout :paying}}
      :paying    {:on {:success   :confirmed
                       :failure   :browsing}}
      :confirmed {}}}}}}}
Event Source path Target path Notes
:login [:unauthenticated] [:authenticated :dashboard] Target [:authenticated] cascades :initial :dashboard.
:open-cart [:authenticated :dashboard] [:authenticated :cart :browsing] Keyword :cart resolves as sibling of :dashboard; cascades :initial :browsing.
:checkout [:authenticated :cart :browsing] [:authenticated :cart :paying] Keyword :paying is sibling of :browsing inside :cart.
:logout [:authenticated :cart :paying] [:unauthenticated] Deepest-wins walks :paying (no match), :cart (no match), :authenticated (match). Vector target is absolute. Exit cascade: :paying:cart:authenticated.

For the :logout row, the LCA of [:authenticated :cart :paying] and [:unauthenticated] is the root, so the exit cascade runs every level of the source path; the entry cascade enters just :unauthenticated.

Capability scope

Hierarchical compound states are claimed by the v1 CLJS reference per §Capability matrix. What hierarchy gives you here:

  • Nested :states and :initial cascading.
  • Vector and keyword :target forms.
  • LCA-based entry/exit cascading.
  • Deepest-wins transition resolution with parent fallthrough.

Out of scope of this section — see the cross-reference for each:

  • Parallel regions — first-class capability per §Parallel regions; the N-machines-per-region substitute in CP-5-MachineGuide §Substitutes remains the right answer when regions are independent features.
  • History pseudo-states — first-class capability per §History states; a :type :history node under a compound's :states re-enters the compound's last-active configuration (shallow / deep / default-target).
  • onDone final-state notification — substitute: explicit [:raise ...] from the leaf state's :entry.

:always, :after, and :spawn are all specified independently of the hierarchy work above (see §Eventless :always transitions, §Delayed :after transitions, and §Declarative :spawn). All three are state-node keys whose semantics compose with the hierarchical entry/exit cascade described above.

Parallel regions

A machine may declare :type :parallel at the root and a :regions map keyed by region name. Each region is a full state-tree (its own :initial, :states, optional :on / :tags / :after / :spawn / :always on each state node). All regions are active simultaneously when the machine is active; the snapshot's :state is a map of region-name → that region's keyword-or-vector-path; transitions are broadcast across regions (every region's active state-node independently decides whether the event matches one of its :on keys); the run-to-completion macrostep drain settles every region before the snapshot commits.

xstate/SCXML term: parallel state / <parallel>. The motivating use case is orthogonal axes of one feature — one form with three independent axes (data cardinality / form validity / display mode), one widget with display + interaction state, one page whose render-mode is a function of three independent inputs. Parallel regions avoid the AND-state combinatorial explosion: three axes of three states each shrink from 3^3 = 27 flat states to nine states across three regions.

(rf/reg-machine :ui/nine-states
  {:type    :parallel
   :data    {:items [] :error nil}                            ;; shared across all regions
   :guards  {:empty? (fn [{d :data}] (zero? (count (:items d))))}
   :actions {:bump   (fn [{d :data}] {:data (update d :count inc)})}
   :regions
   {:data
    {:initial :nothing
     :states  {:nothing  {:tags #{:data/idle} :on {:fetch :loading}}
               :loading  {:tags #{:data/loading :data/transient}
                          :on   {:loaded :resolving :failed :error}}
               :resolving {:always [{:guard :empty? :target :empty} {:target :some}]}
               :empty    {:tags #{:data/empty}}
               :some     {:tags #{:data/some}}
               :error    {:tags #{:data/error}}}}
    :form
    {:initial :neutral
     :states  {:neutral   {:tags #{:form/neutral}   :on {:submit-invalid :incorrect
                                                          :submit-valid   :correct}}
               :incorrect {:tags #{:form/invalid}   :on {:edit :neutral}}
               :correct   {:tags #{:form/success}   :on {:edit :neutral}}}}
    :mode
    {:initial :active
     :states  {:active {:tags #{:mode/active}   :on {:archive :done}}
               :done   {:tags #{:mode/done :mode/terminal}}}}}})

After the machine has settled at every region's :initial, the snapshot is:

{:state {:data :nothing :form :neutral :mode :active}
 :data  {:items [] :error nil :count 0}
 :tags  #{:data/idle :form/neutral :mode/active}}

When to reach for parallel regions

Parallel regions are for the multi-axis-of-one-domain case: one form with three orthogonal axes (data / form / mode), one connection with auth + lifecycle + request-queue, one widget with display + interaction. They share a single :data blob because the axes share a domain — the data the regions read and write is the same data, just sliced differently by each region's state.

If your axes are conceptually separate features (multiple tabs each with their own state, boot phases plus diagnostics, an audio/video player whose two regions share nothing but the play/pause event), you don't want parallel regions — you want N separate machines colocated in app-db. See CP-5-MachineGuide §Substitutes for the N-machine pattern and worked example. Per §9.4 (Shared :data lock), per-region :data is not supported; if your axes need encapsulated :data, that's the substrate telling you to register N machines, not retrofit per-region data into one parallel machine.

Snapshot shape

The snapshot's :state becomes the third arm described in §Snapshot shape — a map of region-name → that region's keyword-or-vector-path:

;; flat region — the value is a keyword
{:state {:data :loading :form :neutral :mode :active} ...}

;; compound region — the value is a vector path INSIDE that region
{:state {:auth [:authenticated :dashboard] :lifecycle :idle} ...}

Nested parallel regions (a region whose own state-tree declares :type :parallel) are not supported in v1. The validator rejects them at registration with :rf.error/machine-parallel-nested-not-supported. Two-level nesting can be modelled as a flatter cross-product or, more idiomatically, as multiple top-level parallel-region machines.

The :data slot is shared across every region — there is no :data slot on a region body, and there is no per-region :data slot inside the snapshot. Region states see and write the same :data map; the action-effect contract is unchanged ((fn [data event] {:data {...}})).

Initial state

The initial snapshot's :state is the map of region-name → that region's initial cascade. Each region's :initial is required (just like a top-level flat machine's :initial); a region body whose own root is a compound state cascades through that region's :initial chain (per §Initial-state cascading). Each region's :entry cascade runs once at machine boot.

;; given the :ui/nine-states example above:
(@(rf/sub-machine :ui/nine-states))
;; => {:state {:data :nothing :form :neutral :mode :active}
;;     :data  {:items [] :error nil}
;;     :tags  #{:data/idle :form/neutral :mode/active}}

Transition broadcast

Every event delivered to a parallel-region machine is broadcast to every region. Each region resolves the event through its own active state's deepest-wins lookup (per §Transition resolution) — region A's active state checks its :on, region B's active state checks its :on, and so on, independently. The runtime collects each region's resolved transition, applies them in region-declaration order against the shared :data (so each region's action sees the prior region's :data writes), and commits the merged result.

Three outcomes per region:

  • Region's state has a matching :on entry whose guard passes. That region transitions: exit cascade → action → entry cascade. :fx accumulated by the region's actions joins the macrostep's :fx vector.
  • Region's state has no matching :on entry. That region's :state is unchanged. No :rf.machine.event/unhandled-no-op fires for the region alone unless every region declines the event (see below).
  • Region's matching :on entry has a guard that returns false. Same as "no match" — region stays put, no per-region trace fires.

If every region declines the event (no region matched a transition), the machine as a whole emits the benign :rf.machine.event/unhandled-no-op trace exactly once, matching the flat-machine semantics (an unhandled event is a no-op, not an error — xstate-v5 parity, see §Transition resolution). If any region handled the event, the snapshot commits with that region's transition applied and no no-op trace fires.

The post-broadcast snapshot's :state is the map of region-name → that region's new state value. Regions that didn't transition keep their prior value in place.

Per-region :always / :after / :spawn scoping

Each region's state-node keys (:always, :after, :spawn, :entry, :exit) operate scoped to that region:

  • :always — the macrostep's microstep loop runs per region. After a region's event-driven transition, that region's new state's :always entries are checked; matching guards fire transitions in that region. Other regions are not re-evaluated for :always on a sibling region's microstep; their own :always checks fire when that region itself transitions. Each region's microstep cascade settles to its own fixed point before commit.
  • :after — an :after timer is scheduled / cancelled when its region's state entry / exit fires. One region's timer firing dispatches [:rf.machine.timer/after-elapsed delay-key epoch] back into the parent; the broadcast routes the synthetic event to every region; the bearing region picks it up via pick-after-transition (per §Delayed :after transitions); sibling regions decline the synthetic event and stay put.
  • :spawn — a region's :spawn-bearing state spawns / destroys actors bound to that region's state. The runtime-owned tracking slot at [:rf/runtime :machines :spawned <parent-id> <invoke-id>] (per §Declarative :spawn) uses an :spawn-id that prefixes the region name onto the in-region prefix-path. Sibling regions never see the spawn / destroy cascade.
  • :entry / :exit — fire on the region's own transitions, never on a sibling region's transitions.

Tags compose across regions

A parallel-region machine's :tags slot on the snapshot is the union of every active state's :tags across every active region. Tag union (per §State tags) extends naturally:

  • For each region, walk the region's active configuration (root → leaf for compound regions; the single state for flat regions); union every active state-node's :tags.
  • Across regions, union the per-region results.
;; given the example above, after settling the initial state:
;; - region :data is at :nothing, which carries #{:data/idle}
;; - region :form is at :neutral, which carries #{:form/neutral}
;; - region :mode is at :active, which carries #{:mode/active}
;; → snapshot's :tags is #{:data/idle :form/neutral :mode/active}

The framework sub :rf/machine-has-tag? (per §Querying tags) works unchanged — it asks "does the union contain this tag?" and the answer is yes iff any active state-node across any region declared the tag.

Worked example — broadcast, shared :data, tags compose

;; Both regions react to :reset; the action lives in the parent's :actions
;; map and is referenced from each region's :reset transition.
(rf/reg-machine :ui/example
  {:type    :parallel
   :data    {:count 0}
   :actions {:bump-count (fn [{d :data}] {:data (update d :count inc)})}
   :regions
   {:left
    {:initial :a
     :states  {:a {:tags #{:left/a} :on {:reset {:target :a :action :bump-count}}}}}
    :right
    {:initial :x
     :states  {:x {:tags #{:right/x} :on {:reset {:target :x :action :bump-count}}}}}}})

;; Initial snapshot:
;; {:state {:left :a :right :x} :data {:count 0} :tags #{:left/a :right/x}}

(rf/dispatch-sync [:ui/example [:reset]])
;; Both regions handle :reset (self-transition + :bump-count). The action
;; runs ONCE PER REGION against the shared :data — :count goes 0 → 1 → 2.
;; Snapshot after the macrostep:
;; {:state {:left :a :right :x} :data {:count 2} :tags #{:left/a :right/x}}

The action is run by each region that handles the event; shared :data flows through each region's actions sequentially. If you want an event to count once, register a coordinating action at the parent-machine level rather than per-region, or set up the regions so only one handles the event.

Capability gating

Parallel regions are claimed as :fsm/parallel-regions in the v1 CLJS reference per §Capability matrix. Ports that don't claim it raise :rf.error/machine-grammar-not-in-v1 on :type :parallel at registration time. The schema extension (:rf/state-node gaining :type + :regions; :rf/machine-snapshot's :state widened to the third arm) is documented in Spec-Schemas §:rf/transition-table and §:rf/machine-snapshot.

Substitutes — when to use N machines instead

As noted in §When to reach for parallel regions, parallel regions are the right answer when the regions are orthogonal axes of one feature with one shared :data. The N-machines-per-region substitute documented in CP-5-MachineGuide §Substitutes — separate [:rf/runtime :machines :snapshots <id>] entries coordinated via cross-actor dispatch — is the right answer when the regions are conceptually independent features that don't share data. Both patterns ship together; choose by domain shape.

Trace events

Parallel-region transitions emit one :rf.machine/transition macrostep trace per dispatched event (matching flat / compound machines). The trace's :before and :after payloads carry the full snapshot (including the :state map shape). The internal per-region transitions and their microsteps surface through the per-region :rf.machine.microstep/transition events (per §Eventless :always transitions §Trace events); each carries a :region tag identifying which region produced the microstep so consumers can subscribe to per-region streams.

Cross-references

Eventless :always transitions

An :always transition fires automatically when its guard becomes true — no event needed. xstate/SCXML term: transient or eventless transition. The pattern handles "the snapshot just changed; if condition X is now true, immediately move to state Y" without the author having to manually :raise a synthetic event from every action that could enable the condition.

:always is a state-node key (alongside :on, :entry, :exit, :spawn) holding a vector of guarded transition specs. Checked after entry (or after any transition that lands in this state). First matching guard wins; subsequent entries in the vector are not evaluated.

{:checking-form
 {:always [{:guard :form-valid?   :target :submitting}
           {:guard :form-invalid? :target :show-errors}]
  :on     {...}}}

Microstep loop within drain

:always extends Level 3 of §Drain semantics with a microstep cascade. Within a single machine event:

  1. Apply the resolving transition (action + target).
  2. Drain the :raise queue (depth-first, as before).
  3. Check :always of the current state. If a guarded entry matches (first-match-wins), apply that transition (action + target), accumulating its :fx; loop back to step 2 to drain any new :raise queue, then re-check :always.
  4. Fixed point reached when no :always entry in the current state matches. Commit the snapshot.
  5. Emit accumulated :fx.

The whole cascade — initial transition, raise drain, every microstep, every microstep's raise drain — commits once, atomically. External observers see only the final settled state. This is xstate/SCXML macrostep semantics: the externally-observable transition is the fixed point of the microstep loop.

Order with :raise

Within a single microstep, drain :raise first, then check :always. The combined macrostep is the fixed point of (:raise drain + :always check). Rationale: :raise is explicit transition-chaining the author wrote; :always is an implicit consequence of the resulting state. Authors expect the explicit chain to settle before the implicit check fires.

:raise semantics within a single transition are unchanged — only the macrostep envelope grows.

Bounded depth

Default microstep depth limit: 16 (matching :raise-depth's default). User-configurable at frame-config level (:always-depth-limit). Exceeding the limit:

  • Emits :rf.error/machine-always-depth-exceeded with :tags {:machine-id <id> :depth <limit> :path [<state> <state> ...]}.
  • Halts the cascade with the snapshot uncommitted — external observers do not see the partial path.
  • Recovery: :no-recovery (the runtime cannot guess the author's intent for a non-converging cycle).

The depth counter is separate from the :raise depth counter — a microstep that itself raises events does not double-count. The two limits compose: each microstep can raise up to 16 events, and the macrostep can include up to 16 microsteps.

Hierarchy interaction

When the cascade enters a compound state, :always is checked at every entered level, deepest-first. This matches xstate/SCXML and the existing entry-cascade order (§Entry/exit cascading along the LCA) — the leaf has the most specific knowledge of its own validity, so it gets first chance to redirect.

A match at any level resolves the microstep and the loop returns to step 2.

Self-loop forbidden at registration

A state whose :always targets itself with the same :guard reference (or no guard) is rejected at registration time:

{:checking
 {:always [{:guard :ready? :target :checking}]}}    ;; rejected

make-machine-handler walks every :always entry at construction time and surfaces :rf.error/machine-always-self-loop with :tags {:state <state-keyword> :machine-id <id>} for any entry whose :target resolves to the declaring state itself (a keyword target equal to the state's own key, or a vector target equal to the state's own path). Rationale: an eventless :always that re-targets the state it just re-entered re-evaluates the same guard — it either fires repeatedly to depth-exceeded (if the guard remains true) or is a no-op (if the guard flips on the first hit); in both cases the author intended something else. Catch the typo at registration; surface the topology bug.

The rejection is decidable at registration purely from the entry's :target (registration cannot know whether a guard's truth would change between re-entries). A genuine "re-enter on a changed condition" need is expressed by targeting a distinct intermediate state (or by :after for a re-arming timer), not by a self-:target.

An internal :always — one with no :target, only an :action — is NOT a self-loop and is permitted: it is the canonical action-microstep ({:guard :more? :action :step}) whose action flips the guard false and the microstep loop settles (per §What :always is not). Only an explicit self-:target is rejected.

:source classification — :always

:always microsteps run intra-macrostep and do NOT produce their own dispatch envelope — the resolving event's envelope is the only :rf/dispatch-envelope for the whole macrostep (per §Microstep loop within drain). However, the per-microstep :rf.machine.microstep/transition trace (see §Trace events) carries :source :always so tools can filter "show only :always-driven microsteps" without needing to disambiguate from :on-driven transitions. The closed-set value :always is reserved on :rf/dispatch-envelope's :source for forward consistency with the vocabulary (per Spec-Schemas §:rf/dispatch-envelope) — should a future revision lift :always to a substrate-dispatched event (today it's a pure microstep), the value already names the trigger correctly. Per rf2-ejtpd.

Trace events

The runtime emits trace events at two levels, so tools can subscribe at the granularity they need:

  • Per-microstep :rf.machine.microstep/transition — one event per microstep with :tags {:machine-id <id> :from <state> :to <state> :microstep-index <n> :source :always}. Tools that want to see the inner cascade (visualisers, debuggers) consume these. The :source :always tag is stamped per rf2-ejtpd so the trigger kind is uniform with the dispatch-envelope vocabulary.
  • Outer macrostep :rf.machine/transition — the existing event, augmented with :tags { ... :microsteps <count>} carrying the total number of microsteps in the macrostep. Tools that want only externally-observable transitions (UI inspectors, replay panels) consume this and ignore the per-microstep stream.

Both levels are emitted unconditionally; consumers filter.

Guard references

Guards in :always resolve against the machine's :guards map (per §Registration and the machine-scoped lock per §Resolved decisions). There is no separate registry, no global lookup. make-machine-handler walks every :always entry's :guard slot at registration time and verifies the keyword resolves; misses surface as :rf.error/machine-unresolved-guard exactly as for :on transitions.

Worked example — quiz

{:initial :asking
 :guards  {:enough-correct? (fn [{data :data}] (>= (:correct-count data) 10))}
 :actions {:count-correct   (fn [_] {:data {:correct-count inc}})
           :count-wrong     (fn [_] {:data {:wrong-count inc}})}
 :states
 {:asking
  {:always [{:guard :enough-correct? :target :winner}]
   :on    {:answer-correct  {:action :count-correct}
           :answer-wrong    {:action :count-wrong :target :loser}}}
  :winner {...}
  :loser  {...}}}

Walkthrough: when the user dispatches [:quiz [:answer-correct]], the machine's macrostep runs:

  1. :asking's :answer-correct transition fires; :count-correct increments :correct-count (no :target, internal transition — the snapshot stays at :asking).
  2. Microstep check: :asking's :always evaluates :enough-correct?. If :correct-count is now ≥ 10, the guard is true; the microstep transitions to :winner.
  3. Fixed point: :winner's :always (if any) is checked; assume it has none. Commit.
  4. Trace surface: one outer :rf.machine/transition (:asking:winner, :microsteps 1) plus one per-microstep :rf.machine.microstep/transition.

External observers see :asking:winner. The "answer counted, still asking" intermediate state is invisible — exactly the property :always exists to provide.

What :always is not

  • Not a mid-transition slot. :always lives only on a state node (alongside :on, :entry, :exit); it is not a key inside a transition spec. The microstep loop is the cascade mechanism — there is no "always after this action."
  • Not on the root machine. :always is a state-node key; the root has :initial as its cascade entry-point. (A root-level "fire as soon as the machine starts" need is met by :initial cascading into a leaf whose :always fires.)
  • Not allowed as a self-targeting :always (see above) — registration error.
  • Not a substitute for :after. :after is for time-delayed transitions; :always fires immediately on guard truth. They are independent capabilities; see §Delayed :after transitions for the full delayed-transition semantics. Both can co-exist on the same state node — they are independent slots.

State tags

A state node may declare :tags <set-of-keywords>. At every transition, the runtime walks the active configuration, unions every active state-node's tag set, and stamps the result onto the snapshot at :tags. A framework sub asks the predicate question — "does this machine's snapshot carry this tag?" — without enumerating every nested path that contributes to the answer.

The motivating use case is the Nine States pattern (Pattern-NineStates.md): a page-level convention whose render decisions slice across three orthogonal axes (data cardinality, form validity, mode). Tags carry the per-axis intent (:data/loading, :form/invalid, :mode/done) so view-level subs can ask query-shaped questions without inventing N boolean discriminator subs per state.

{:initial :editing
 :states
 {:editing  {:tags #{:active :editable}
             :on   {:archive :archived}}
  :archived {:tags #{:done :read-only :terminal}}}}

After entering :archived, the snapshot looks like:

{:state :archived
 :data  {<...>}
 :tags  #{:done :read-only :terminal}}

Semantic contract

  • :tags is a set of keywords on a state node. Order doesn't matter; duplicates collapse. The implementation tolerates the obvious alternative shapes (a vector or single keyword) by coercing to a set; the canonical form is [:set :keyword] per Spec-Schemas §:rf/state-node.
  • The runtime computes (apply set/union (map :tags active-state-nodes)) at every transition commit (after :always microsteps reach fixed point, before the macrostep's :rf.machine/transition trace fires — so traces carry the new tag set) and stores the result at [:rf/runtime :machines :snapshots <id> :tags] on the snapshot.
  • For a flat machine the active set is the single named state.
  • For a compound machine it's every state along the active path (root → every compound ancestor → leaf). This matches XState/SCXML's "any state in the active configuration carrying the tag is enough."
  • :tags is read-only for users. Actions cannot return :tags in their effect map; the runtime owns the slot. It is a pure projection of :state — set the state, the tags follow.
  • The framework reserves the :rf/* and :rf.*/* keyword namespaces (per Conventions.md §Reserved namespaces); user-declared :tags must not use those prefixes. Any unreserved namespace is fair game, including dotted forms like :ui.state/loading.

Snapshot shape change

Strictly additive — the :rf/machine-snapshot schema (see Spec-Schemas §:rf/machine-snapshot) gains one optional key:

{:state <fsm-keyword-or-path>
 :data  <map>
 :tags  #{<keyword>, ...}            ;; NEW — derived at commit; optional
 :meta  {<...>}}

When the active configuration's tag union is empty (no active state declares :tags), the runtime elides the :tags key entirely. Pre-tag machines are byte-identical to post-tag machines that declare no tags — no snapshot grew, no print/read round-trip changed shape, no downstream reader has to special-case anything.

Implementations may also legally carry :tags #{} instead of eliding; both shapes are conformant. The CLJS reference elides — that's the optimisation conformance fixture tags-empty-when-no-declaration asserts.

Querying tags — the :rf/machine-has-tag? sub

The framework ships one sub:

;; framework-shipped — registered alongside :rf/machine in the machines ns
(reg-sub :rf/machine-has-tag?
  (fn [db [_ machine-id tag]]
    (contains? (get-in db [:rf/runtime :machines :snapshots machine-id :tags]) tag)))

User call sites:

;; predicate
@(rf/subscribe [:rf/machine-has-tag? :ui/nine-states :data/loading])
;; => true | false

;; sugar matching sub-machine's pattern
(rf/machine-has-tag? :ui/nine-states :data/loading)
;; => reaction wrapping the registered sub

Reading the whole tag set is the normal snapshot read:

@(rf/sub-machine :ui/nine-states)
;; => {:state ... :data ... :tags #{:data/loading :form/neutral :mode/active}}

The sub is derived — it reads the snapshot via get-in rather than chaining off :rf/machine — so a view that only cares about whether a specific tag is present re-renders only when the containment-bit flips, not on every tag-set change. Reagent's built-in equality dedup gates the boolean return.

Compatibility

Strictly additive. Machines that declare no :tags keys produce snapshots without a :tags slot; existing views, subs, and traces don't care. The :rf/machine-snapshot schema's {:optional true} covers the migration. No existing public name collides — :tags was previously unused in the state-node grammar (the :meta slot was the only carrier of state-level tooling-visible metadata, and per-state :meta is still independently allowed and not synonymous with :tags).

Print/read survives: :tags is #{<keyword>} — a set of keywords; both halves are EDN-printable and EDN-readable. The Tool-Pair epoch buffer and SSR hydration paths handle :tags automatically because they round-trip the snapshot as opaque data.

Tags on states only — not transitions

Per the locked design decision (§9.3): :tags is a state-node slot, not a transition-spec slot. Transitions don't carry tags — the question "is this transition tagged" is already answered by the existing trace-event vocabulary (:source, :op-type). Adding transition tags later is non-breaking; today's design says no.

What tags are not

  • Not a transition-driver. Guards' inputs are :data + the event, not the tag set. A transition can't react to a tag flipping on; if you need that, the right answer is to change the state directly (an :always transition guarded on :data is the canonical mechanism).
  • Not a :meta synonym. Per-state :meta (the long-standing tooling-visible slot, e.g. {:terminal? true}) lives alongside :tags and is independently queryable via (machine-meta id). Tags are about runtime active-configuration projection; :meta is about static state-node metadata.
  • Not user-writable on the snapshot. Actions can't return :tags in their {:data :fx} effect map; the slot is runtime-owned.
  • Not a substitute for :rf/machine. Views that need the whole snapshot still subscribe to :rf/machine; :rf/machine-has-tag? is for the predicate-shaped query. Both are first-class.

Worked example — read the page's render state in one tag-query

A view that wants "render the loading spinner whenever any data-loading state is active" today writes:

(rf/reg-sub :ui.state/loading?
  :<- [:todos/status]
  (fn [status _] (= status :loading)))

(when @(rf/subscribe [:ui.state/loading?])
  [view-loading])

That works for the flat-status case; it doesn't scale to "loading, OR validating, OR retrying" without adding three more subs. With tags:

{:loading     {:tags #{:data/loading} :on {...}}
 :validating  {:tags #{:data/loading} :on {...}}
 :retrying    {:tags #{:data/loading :data/retry} :on {...}}}

(when @(rf/machine-has-tag? :todos/editor :data/loading)
  [view-loading])

The view doesn't enumerate the three states. New :loading-flavoured states added later carry the tag automatically; the view picks them up at no cost.

Trace events

:tags is recomputed on every :rf.machine/transition and fires under the same trace event — there's no separate :rf.machine.tag/changed trace. The committed snapshot's :tags slot is visible in the existing trace's :after payload; observers that care about tag changes compare (:tags (:before tr)) against (:tags (:after tr)).

If a future use case wants per-tag granular tracing, a :rf.machine.tag/changed trace event can be added additively without breaking the read pattern above.

Capability gating

:tags is :fsm/tags in the capability matrix (per §Capability matrix) — claimed by the v1 CLJS reference. Ports that don't claim it raise :rf.error/machine-grammar-not-in-v1 on :tags at registration time.

Cross-references

Delayed :after transitions

An :after transition fires after a specified time delay, no event needed. xstate/SCXML term: delayed transition. The pattern handles "after N milliseconds in this state, time out" without the author having to wire a :dispatch-later from :entry and a matching :cancel-dispatch-later on every other transition out of the state.

:after is a state-node key (alongside :on, :entry, :exit, :always, :spawn) holding a map of ms → transition-spec. Each entry runs an independent timer; on expiry, the corresponding transition fires (subject to its :guard). Entering the state schedules every entry's timer; exiting the state advances an epoch counter so in-flight timers from the prior visit are detected as stale and silently ignored.

{:loading
 {:after {5000  :timeout
          30000 {:guard :still-loading? :target :hard-error}}
  :on    {:loaded :ready
          :failed :error}}}

Value shape

Each :after map entry is <delay> → <transition-spec>. Both halves admit multiple forms.

Delay (the key) — three forms:

  • pos-int? — literal milliseconds, computed at registration time. The default form for fixed timeouts ({30000 :timeout} — fire after 30 seconds).
  • Subscription vector[<sub-id> & <args>] resolved through the same machinery as subscribe. Canonical for app-state-derived delays: the delay reads from a flow / sub whose value reflects user preferences, feature-flag config, or any other app-db-derived setting. Re-resolves on subscription change (see §Dynamic delay re-resolution). Example: {[:sub :timeout-config :auth] :timeout} reads the auth-phase timeout from a registered sub.
  • (fn [{:keys [snapshot]}] ms) — fn-valued delay, called once at state entry against the entering snapshot via the unified context-map. Returns a pos-int? ms value. The escape valve for delays computed from local machine :data (the snapshot is {:state :data :meta?}); :data is the only source of dynamic input that the subscription form cannot reach without a subscription wrapper. Example: {(fn [{:keys [snapshot]}] (* 1000 (:retry-count (:data snapshot)))) :retry}.

The subscription form is the canonical answer for "the delay should track an app-level configuration"; the fn form is the local answer for "the delay depends on this machine's own :data." Literal pos-int? covers the common case where the delay is a constant.

Transition spec (the value) — three forms, identical to an :on clause:

The :after value is resolved through the same value-form grammar as an :on clause (per §Transitions) — the runtime normalises both through one shared candidate-walk, so the two slots can never drift apart.

  • :keyword — sugar for {:target <keyword>}. The simple "fire after N ms; transition to state X" case.
  • :rf/transition map — full transition spec with the same shape as an :on slot: {:guard <guard-ref> :target <target> :action <action-ref> :meta <map>}. Guards resolve machine-locally against the spec's :guards map, exactly as for :on and :always. If :guard is present and evaluates false at timer expiry, the transition is suppressed (the timer is treated as "fired and discarded; no transition") and the runtime emits a :rf.machine.timer/fired trace with :fired? false; the snapshot is unchanged and other in-flight :after timers continue running (per §Multi-stage interaction with :guard). The slot shape — :guard, :target, :action, :meta — matches the canonical :rf/transition shape used by :on (per §Transitions) and :always.
  • guarded candidate-vector[{:guard g1 :target s1 :action a1} {:guard g2 :target s2} … {:target sN :action aN}], a vector of :rf/transition maps resolved first-guard-pass-wins at timer expiry, exactly as an :on clause's multiple-candidate form. The candidates are walked in declaration order; the first whose :guard passes fires (running its :action and routing to its :target); an unguarded candidate is the unconditional fallback that ends the list. If every candidate's guard fails and there is no unguarded fallback, the firing is guard-suppressed — no transition, no epoch advance, the :rf.machine.timer/fired trace carries :fired? false, and sibling :after timers continue (per §Multi-stage interaction with :guard). This is the canonical "checkpoint with escalation/fallback at the same deadline" shape — e.g. {6000 [{:guard :handshake-ok? :target :connected} {:target :failed :action :record-error}]} connects iff the handshake completed by the 6 s deadline, otherwise records the error and fails.

Sugar normalises at registration time: {5000 :timeout} is equivalent to {5000 {:target :timeout}}, and a single map is the one-element case of the candidate-vector. The runtime sees the desugared form.

;; Three delay forms in one state node:
{:loading
 {:after {30000                        {:target :timeout :guard :no-progress?}     ;; literal ms
          [:sub :timeout-config :slow] {:target :warn :action :log-slow}            ;; subscription
          (fn [{:keys [snapshot]}] (* 1000 (-> snapshot :data :retry-count)))
                                       :retry}                                      ;; local fn
  :on    {:loaded :ready
          :failed :error}}}

Wall-clock from state entry

Each :after timer counts from the moment the machine enters the state (the :entry-cascade-final timestamp captured by the runtime at commit time). If the state has multiple :after entries, each timer counts independently from the same entry-time — a state with {:after {5000 :warn 30000 :timeout}} schedules both timers concurrently at entry; the 5000 ms timer is not chained off the 30000 ms timer.

Re-entering the same state (a transition whose :target lands back in the same state, or a parent-cascade that re-enters the leaf) restarts every :after timer from the new entry-time — the prior visit's in-flight timers go stale via the epoch advance (§Epoch-based stale detection); the new visit's timers are scheduled fresh. There is no preserved "elapsed-so-far" across state re-entry — by design (:after is per-state-entry semantics, not per-state-occupancy).

Whichever fires first wins

A state may have multiple in-flight transition triggers concurrently:

  • Multiple :after timers — every entry in the :after map is its own independent timer.
  • :on <event> transitions — any user-dispatched event the state's :on map handles.
  • :always transitions — guards that may newly become true after an action commits.
  • :spawn's child completion — the spawned child dispatching back into the parent.

Whichever fires first causes the transition; the others are cancelled as part of the standard exit cascade. The mechanism:

  1. The first trigger to dequeue at the parent's handler (timer expiry, user dispatch, child dispatch, :always microstep) drives the transition.
  2. The transition's exit cascade runs (per §Entry/exit cascading along the LCA).
  3. As part of the exit cascade, the runtime advances the exited node's per-path :rf/after-epoch entry — every other in-flight :after timer from the just-exited state goes stale on its eventual firing.
  4. Any :spawn-spawned child is destroyed via :rf.machine/destroy (the desugared :exit action). Per the §Cancellation cascade — in-flight :rf.http/managed aborts contract, in-flight :rf.http/managed requests inside the destroyed child cascade to abort — :after firing is one trigger of the same cancellation cascade as a parent-destroys-child shutdown.
  5. User-dispatched events queued for the just-exited state but not yet drained are processed by the now-current state's :on map (which may handle them, route to :* wildcard, or — when no level matches — resolve as a benign :rf.machine.event/unhandled-no-op).

The cancellation cascade is uniform across triggers — the runtime does not distinguish "the timer fired" from "the user dispatched" from "the child completed" at the cascade level; each is just an event at the parent's handler boundary that resolves to a transition out of the state. The :rf.machine.timer/stale-after traces (§Trace events) are how observers see "this :after was racing and lost."

Dynamic delay re-resolution

A subscription-vector delay ([:sub-id & args]) is re-resolved when its underlying subscription value changes:

  1. At state entry, the runtime resolves the subscription, captures the current ms value, and schedules a timer for that delay.
  2. While the timer is in flight, the runtime watches the subscription. If its value changes (a new app-db value flows through the sub) the runtime:
  3. Cancels the in-flight timer (best-effort via re-frame.interop/cancel-scheduled!; epoch-based stale detection backstops cancellation per §Epoch-based stale detection).
  4. Restarts the timer from the current moment with the newly-resolved ms value. The window does not carry over elapsed-so-far; the replacement timer counts from the re-resolution time.
  5. When the timer expires, the runtime fires the transition (subject to :guard).

Why restart from the current moment, not extend/shorten the existing timer: restart semantics is the simplest mental model and the easiest to reason about — at any moment, the timer's countdown reflects the current subscription value. Extending or shortening an existing timer requires the user to track elapsed-so-far, makes the wall-clock interaction non-monotonic (a timer set for 30 s could fire at 15 s if shortened, or never fire if perpetually extended), and complicates the :rf.machine.timer/scheduled trace stream (does the trace fire on each shortening?). Restart-from-now keeps the contract: every :rf.machine.timer/scheduled trace marks a fresh wall-clock window; every :rf.machine.timer/fired measures from the most-recent :scheduled.

Stale-detection composes. Each restart advances the scheduling node's per-path :rf/after-epoch entry (or a per-:after-entry sub-counter; implementation choice — the contract is "the prior in-flight timer is stale on firing"); the cancelled prior timer fires stale and emits :rf.machine.timer/stale-after.

Trace. A subscription-driven restart emits a paired :rf.machine.timer/cancelled (the prior timer cancelled by re-resolution; :tags {:machine-id <id> :state <state> :delay <prior-ms> :epoch <e> :reason :on-resolution :sub-id <sub-id>}) followed by a fresh :rf.machine.timer/scheduled (the new timer). Tools that distinguish "the subscription changed" from "the state exited" filter on :reason :on-resolution vs :reason :on-exit on the same /cancelled event — every cancellation path emits the single unified event.

Function-form delays do NOT re-resolve. A (fn [{:keys [snapshot]}] ms) delay is called once at state entry; the snapshot's :data may change later but the timer does not re-evaluate. Authors who want a :data-derived delay that re-resolves on :data change use the subscription form ([:sub :machine-data-derived-delay <machine-id>] whose body reads from [:rf/runtime :machines :snapshots <machine-id> :data ...]) and pay the subscription cost; the fn form is the cheap "compute once at entry" escape valve.

Subscription form under SSR. Resolved at server render time (the runtime materialises the value), but scheduling is suppressed per §SSR mode; the resolved ms value flows into the hydration payload as part of the snapshot's trace state but no timer fires server-side.

Multi-stage interaction with :guard

When multiple :after entries declare :guards, each timer's guard is checked independently at that timer's expiry:

  • Guard returns true (transition fires) — the transition runs through the standard cascade; the exit advances the epoch; remaining in-flight :after timers from the just-exited state go stale on firing.
  • Guard returns false (transition suppressed) — the runtime emits :rf.machine.timer/fired with :fired? false; the snapshot is unchanged; the state does NOT exit; other in-flight :after timers from the same state continue running unchanged. The expired-with-false-guard timer is not re-scheduled — the contract is "fired and discarded." If the author wants a timer that polls a guard until true, the surface is to fire a transition (sugar {30000 :recheck-state}) into a state whose :always evaluates the same guard and re-routes — :after itself is fire-once-per-state-entry.

Concretely: a state declaring {:after {5000 {:guard :slow? :target :warn} 30000 {:target :timeout}}} runs both timers concurrently from entry. At t=5s the 5000 ms timer fires; if :slow? returns false, the transition is suppressed; the 30000 ms timer is still in flight. At t=30s the 30000 ms timer fires unconditionally and the machine transitions to :timeout regardless of :slow?'s eventual truth. The author who writes the 5000 ms-with-guard form is opting for "if the condition is true at the 5 s checkpoint, escalate; otherwise let the longer timeout decide" — exactly the multi-stage timeout pattern.

Per-entry candidate-vector resolution. The two interactions above operate at the level of distinct :after entries (different delay-keys, each its own timer). A single :after entry whose value is a guarded candidate-vector resolves its candidates at one timer expiry, walked first-guard-pass-wins exactly as an :on clause resolves multiple candidates (per §Transitions) — there is no second grammar. At expiry the runtime walks the candidate list in declaration order; the first candidate whose :guard passes fires (and no later candidate is evaluated); an unguarded candidate is the unconditional fallback. The guard-suppressed branch above applies to the candidate-vector as a whole: a firing is suppressed only when every candidate's guard fails and no unguarded fallback ends the list. This makes the "escalate-or-fallback at the same deadline" pattern a single :after entry — e.g. {6000 [{:guard :handshake-ok? :target :connected} {:target :failed :action :record-error}]} — rather than two competing entries whose relative firing order an author would otherwise have to reason about.

No-invoke variant

A state with :after but no :spawn is a pure timed-transition state — the canonical shape for splash screens, animation gates, and user-prompt countdown timers. No child machine is spawned; the state's only behaviour is the timer (plus any user :on events).

{:initial :splash
 :states
 {:splash {:after {3000 :main}             ;; show splash for 3 seconds
           :on    {:skip :main}}            ;; or user clicks 'skip'
  :main   {...}}}

The :after slot is independent of the :spawn slot — neither requires the other; both are state-node-level keys per §State nodes. Pure timed-transition states are the simplest :after use case and are exercised by the conformance fixture after-no-invoke-splash per §Capability matrix.

Epoch-based stale detection

Cross-cutting pattern. This is one instance of the stale-detection pattern re-frame2 uses for any async-shaped feature where the receiving state's identity matters. See Pattern-StaleDetection.md for the meta-pattern; the same idiom is used by 012 §Navigation tokens and is the recommended default for future async-shaped substrates. Trace events follow the <feature>/stale-<reason> convention.

Re-frame2 does not introduce a :cancel-dispatch-later fx. Cancellation is unnecessary because every scheduled timer carries an epoch captured at scheduling time, and the receiving handler validates the epoch before firing.

The mechanism:

  1. The machine handler maintains a per-scheduling-node epoch map in :data under the reserved key :rf/after-epoch{<decl-path-vector> <non-negative int>}, each entry counting the visits to the :after-bearing node at that declaring path. The :rf/-namespace inside :data is reserved for runtime-managed keys; user code does not write under it.
  2. On state entry, the handler increments that node's per-path epoch and, for each :after entry, schedules a :dispatch-later carrying the synthetic event [<machine-id> [::after-elapsed <delay-key> <epoch> <decl-path>]]. The exact event shape is implementation-internal; what's contractual is that the node's epoch AND its declaring path travel with the timer.
  3. On timer expiry, the machine handler receives the synthetic event and resolves the scheduling node at the carried <decl-path>, comparing the carried epoch against that node's current per-path entry in :data.
  4. Live — the scheduling node is still on the active path AND the carried epoch matches its per-path entry; the handler resolves the transition (subject to :guard) and fires it through the normal Level 3 drain.
  5. Stale — the scheduling node has been exited (no longer on the active path), or its per-path entry advanced (a re-entry scheduled a fresh timer); the handler silently ignores it and emits a :rf.machine.timer/stale-after trace event with :tags {:machine-id <id> :state <state> :delay <ms> :scheduled-epoch <e1> :current-epoch <e2>}.
  6. On state exit (any transition that lands the snapshot in a different state, including cross-level transitions per §Hierarchical compound states), only the exited (and newly-entered) :after-bearing nodes' per-path epochs advance. A still-active parent above the LCA keeps its entry, so its in-flight :after timer stays live across a child-only transition.

The epoch is tracked per scheduling node (its declaring state path), not as a single per-machine scalar — a single scalar could not satisfy the §Hierarchy interaction contract, because a child sibling-transition that itself bumps the shared counter would stale a still-active parent's in-flight timer. Each node's exit advances only its own entry; re-entering the same node increments that entry again — timers from the new visit carry a fresh epoch while any prior in-flight timer observes the mismatch.

Drain semantics interaction

:after does not introduce a new microstep loop. The synthetic timer-elapsed event lands in the standard runtime FIFO via :dispatch-later's normal path, and when it dequeues, the machine handler treats it as an ordinary event:

  1. Resolve the synthetic event to its declared transition (via the state's :after map, indexed by the carried delay).
  2. Validate the epoch (above). On mismatch, emit :rf.machine.timer/stale-after and stop — no transition runs.
  3. On match, evaluate :guard (if any). On false, the transition is suppressed and a :rf.machine.timer/fired trace is still emitted (with :fired? false); the snapshot is unchanged.
  4. On match-and-guard-pass, run the transition through the standard Level 2 (exit / action / entry) and Level 3 (drain :raise, check :always) cascade. The transition exit advances the epoch; sibling :after timers from the just-exited state will all be stale by the time they fire.

:after is a deferred event source, not a new layer in the drain hierarchy. Per §Drain semantics, it composes with :raise (which queues before commit) and :dispatch (which queues at the runtime layer) without changing their orderings: the timer-elapsed event arrives at the back of the runtime FIFO, no different from any other :dispatch-later.

Hierarchy interaction

:after on a parent state remains active while the snapshot is in any child of that parent. Multiple :after timers can be in flight simultaneously across hierarchy levels — a parent's 30-second hard-timeout ticks alongside a child's 5-second progress timeout.

Per §Entry/exit cascading along the LCA, the epoch counter advances on any state exit, whether the exit is a leaf-only transition or a multi-level cascade. A leaf-to-sibling transition under the same parent does not exit the parent, so the parent's :after timers stay live; a transition that exits the parent advances the epoch and all of the parent's pending :after timers go stale on next firing.

Implementation note: the epoch is per-machine, not per-level. A leaf-only sibling-transition advances the epoch even though the parent's state is unchanged — but that's fine: the parent's :after was scheduled before the leaf transition, and re-entry of the leaf doesn't re-schedule the parent's timers. To keep parent timers live across leaf transitions, implementations track which :after entries belong to which level on the path and only re-schedule the level(s) that the cascade newly enters. The contract is external — "parent :after outlives sibling-leaf transitions" — and make-machine-handler is responsible for upholding it.

Normative rule (external contract). A parent state's :after timer is suspended-but-not-stale while the snapshot is in any child of the parent: leaf-only sibling transitions inside the same parent MUST NOT cause that parent's pending :after timer to fire as stale on its next match. Conversely, a transition whose LCA is at-or-above the parent MUST advance the epoch such that any of the parent's pending :after timers (from the just-exited visit) all observe a mismatch and silently drop. Implementations that cannot satisfy both clauses with a single per-machine epoch (because a leaf transition advances it) MUST track which :after entries belong to which level on the active path and selectively re-schedule only the levels the cascade newly enters. The per-level re-scheduling sketch above is the recommended implementation; the contract is the observable behaviour, not the implementation strategy.

Multiple :after per state

All entries in an :after map run independently. Whichever timer fires first (and matches its :guard) triggers its transition; the resulting state exit advances the epoch and the remaining timers all go stale. Order between simultaneously-firing timers is implementation-defined — authors should not rely on tie-break behaviour for two timers with the same delay.

:loading
{:after {5000  :timeout                                    ;; first checkpoint
         30000 {:guard :still-loading? :target :hard-error}} ;; final checkpoint
 :on    {:loaded :ready
         :failed :error}}

If :loaded or :failed arrives before 5s, the machine transitions out of :loading; both timers go stale. If neither arrives by 5s, the 5000ms timer fires; the machine transitions to :timeout; the 30000ms timer's eventual firing is stale.

SSR mode

:after no-ops in SSR mode — entry actions do not schedule timers, and the synthetic timer-elapsed event is never emitted. The server renders the current :state statically and the client hydrates that state without timer artefacts. See 011-SSR §:after is no-op under SSR for the SSR-side rule.

This is consistent with :platforms gating on reg-fx (per 011 §Effect handling on the server): timer scheduling is conceptually a :client-only concern. The first client render after hydration can re-fire entry actions to begin scheduling, depending on the implementation's hydration policy — the spec leaves the hydration-handoff timing to the host so long as the snapshot value is preserved.

Spawn under SSR. :rf.machine/spawn and :spawn-driven spawns are also SSR-conditional in the v1 reference: the canonical guidance is that long-lived child actors which exist primarily to drive client-side async work (:http/post, websocket protocols, polling) should be gated on the surrounding event handler running client-side, exactly as with reg-fx :platforms. Server-rendered machine snapshots that happen to land in a state whose :spawn would spawn such an actor should rely on the standard :platforms-style suppression at the spawn-fx layer rather than expecting the runtime to silently no-op the spawn. The hydration payload covers the snapshot value itself; child-actor handlers are not part of the wire shape and re-establish on the client side via the post-hydration entry replay (per 011-SSR).

Clock abstraction

The clock primitives live in re-frame.interop — the existing clj/cljs-split interop layer that already houses platform-dependent atoms, next-tick, etc. Three primitives:

  • re-frame.interop/now-ms — host-clock current time in milliseconds (a long).
  • re-frame.interop/schedule-after! — host-clock setTimeout-equivalent. Returns an opaque handle.
  • re-frame.interop/cancel-scheduled! — best-effort cancellation given the handle. Optional; epoch-based stale-detection makes cancellation an optimisation, not a correctness requirement.

The CLJS realisation uses js/Date.now and js/setTimeout / js/clearTimeout. The JVM realisation uses System/currentTimeMillis and a ScheduledExecutorService. Tests swap the interop layer using existing fixture patterns — there is no new framework-level clock-configuration API; the substitution happens at the namespace level (with-redefs in tests, alternative interop ns alias in conformance harnesses). If :after is exercised on a host whose interop layer hasn't been wired with a clock, the runtime emits :rf.warning/no-clock-configured (an advisory; the host falls back to a host-native clock if available).

Why :rf.warning/* rather than :rf.error/*. The audit (Finding 14) noted that :rf.warning/no-clock-configured is the only :rf.warning/* machine-emit; every other machine-emit is :rf.error/*. The asymmetry is deliberate and confirmed: the recovery is :warned-and-replaced (fall back to the host-native clock — see Spec 009 §Error event catalogue), which is materially different from the :rf.error/* machine emits whose recoveries are :no-recovery, :replaced-with-default, or :logged-and-fallback on a genuine failure path. "Host-native clock available; advisory only" is precisely what :warning severity is for — the request completes correctly, the trace records a configuration drift the operator should address. Promoting to :rf.error/no-clock-configured would conflate "configuration missing but graceful fallback exists" with "operation failed and was recovered" — two different operator intents. The asymmetry is the right shape; per audit Finding 14, the decision is to keep the warning severity.

:source classification — :after-timer

When an :after timer's delay elapses, the substrate dispatches the synthetic [:rf.machine.timer/after-elapsed <delay-key> <epoch> <decl-path>] trigger event into the parent machine. That dispatch stamps :source :after-timer on the dispatch envelope so the Epoch panel's DISPATCH step renders "from :after timer" rather than :unknown (the residual default per rf2-hxj0d). The naming uses the spec's own term — :after — so the value greps back to this section. Per rf2-ejtpd. (Per rf2-1ve9h the prior parallel :rf/dispatch-origin :timer tag was collapsed into :source:after-timer is now the single functional-origin discriminator.)

Trace events

The runtime emits five trace events around every :after:

  • :rf.machine.timer/scheduled — emitted when a timer is scheduled at state entry (or re-scheduled after a subscription-driven re-resolution per §Dynamic delay re-resolution). :tags {:machine-id <id> :state <state> :delay <ms> :epoch <e> :delay-source <:literal | :sub | :fn> :sub-id <sub-id, when :delay-source = :sub>}. One event per :after entry per scheduling.
  • :rf.machine.timer/fired — emitted when a live (epoch-matching) timer's transition resolves. :tags {:machine-id <id> :state <state> :delay <ms> :epoch <e> :fired? <bool>}. :fired? false indicates the guard was checked and returned false; the transition was suppressed and other in-flight timers continue (per §Multi-stage interaction with :guard).
  • :rf.machine.timer/stale-after — emitted when a stale (epoch-mismatched) timer fires. :tags {:machine-id <id> :state <state> :delay <ms> :scheduled-epoch <e1> :current-epoch <e2>}. The transition does not fire.
  • :rf.machine.timer/cancelled — emitted on every :after timer cancellation, regardless of cause (per — one unified event id; the runtime emitted :rf.machine.timer/cancelled-on-resolution only for the subscription-restart case, leaving exit / destroy / supersede / frame-destroy cancellations invisible to the trace stream). :tags {:machine-id <id> :state <state> :delay <prior-ms> :epoch <e> :reason :on-exit | :on-destroy | :on-resolution | :on-supersede | :on-frame-destroy :delay-source <:literal | :sub | :fn, when known> :sub-id <sub-id, when :delay-source = :sub>}. The :reason closed set discriminates the five cancellation paths: :on-exit (state owning the :after was exited), :on-destroy (machine destroyed), :on-resolution (subscription-vector delay re-resolved — the :cancelled-on-resolution case), :on-supersede (a new schedule landed at an already-armed slot), :on-frame-destroy (the bearing frame was destroyed). Payload shape mirrors :rf.machine.timer/scheduled for arm-fire-cancel pairing by (machine-id, state, epoch) so the Xray Handler section's AFTER TIMERS sub-section can render scheduled→fired→cancelled rows directly from the trace stream.
  • :rf.machine.timer/skipped-on-server — emitted in SSR mode when a state's :after entry is reached but timer scheduling is suppressed (per §SSR mode). :tags {:machine-id <id> :state <state> :delay <ms>}. Diagnostic: lets server-side tooling see which timers a real client run would have scheduled.

Tools subscribe to whichever granularity they need: :scheduled for timeline visualisation, :fired for the externally-observable transition, :stale-after for diagnosing "a timer should have fired but didn't" symptoms, :cancelled (with :reason) for the every cancellation cause, :skipped-on-server for confirming SSR no-op behaviour.

Worked example

{:initial :idle
 :guards  {:still-loading? (fn [{data :data}] (:loading? data))}
 :states
 {:idle    {:on {:fetch :loading}}

  :loading
  {:after {5000  :timeout
           30000 {:guard :still-loading? :target :hard-error}}
   :on    {:loaded :ready
           :failed :error}}

  :timeout    {:on {:retry :loading}}
  :hard-error {:on {:reset :idle}}
  :ready      {:on {:reset :idle}}
  :error      {:on {:reset :idle}}}}

Walkthrough (both timers belong to the same [:loading] node, so the node's per-path epoch behaves like a simple counter here). The user dispatches [:fetch]. The machine transitions :idle:loading; [:loading]'s :rf/after-epoch entry advances from 0 to 1; both :after timers schedule with epoch 1 (:rf.machine.timer/scheduled × 2).

  • Path 1 — :loaded arrives at t=2s. The machine transitions :loading:ready; [:loading]'s epoch advances to 2. At t=5s the 5000ms timer fires; epoch carried = 1, current = 2; :rf.machine.timer/stale-after emits; ignored. At t=30s the 30000ms timer fires; same story.
  • Path 2 — neither arrives by t=5s. The 5000ms timer fires; epoch matches; the transition resolves; :rf.machine.timer/fired emits with :fired? true; machine transitions :loading:timeout; [:loading]'s epoch advances to 2. At t=30s the 30000ms timer fires with epoch 1; :rf.machine.timer/stale-after emits; ignored.
  • Path 3 — :loaded doesn't arrive but :loading? is still true at t=30s. The 5000ms timer fired at t=5s and (suppose) the user dispatched [:retry] from :timeout at t=10s; the machine re-entered :loading; [:loading]'s epoch advanced to 3; both timers re-scheduled. The original 30000ms timer (epoch 1, scheduled at t=0) eventually fires; stale; ignored. The newly-scheduled 30000ms timer's guard :still-loading? is consulted at fire time.

External observers see one machine event per externally-visible transition; the timer scheduling and stale-suppression noise stays inside the trace stream.

What :after does not include

  • Recurring timers. :after fires once per state entry. For polling, the user re-enters the state (e.g., :fetching → :waiting → :fetching with :waiting carrying an :after that loops back).
  • Wall-clock delays. :after is relative to entry time, not "fire at 9:00 AM tomorrow." Calendar-scheduled events are an application-level concern; the machine can react to a user-emitted :dispatch-later from outside.
  • Pause / resume. No built-in pause; users pause by transitioning the snapshot out of the state (which makes the timers stale) and back in (which re-schedules with a fresh epoch). The :rf/after-epoch mechanism makes the round-trip idempotent.
  • A :cancel-dispatch-later fx. The epoch mechanism replaces explicit cancellation; the runtime never needs to forget a scheduled timer, only to reject stale ones at expiry.

Spawning — dynamic actors

If machines are event handlers and actors are machines, then a spawned actor is addressable as an event handler whose id is the actor's address — but, per §Liveness is derived from app-db, that handler is NOT a per-instance registrar entry. It is resolved on demand from the actor's snapshot. The mailbox / addressing semantics fall out of dispatch — no new primitive.

Teardown is explicit in v1. Every spawned actor ends its life at a named [:rf.machine/destroy <actor-id>] site — there is no implicit ownership cascade. Auto-cleanup via an opt-in :owned-by relation is a v1.1+ direction; per §Resolved decisions §Auto-cleanup of orphaned actors. The one composed-with cascade is the :rf.http/managed-abort cascade per §Cancellation cascade — in-flight :rf.http/managed aborts, which fires off the explicit :rf.machine/destroy.

Liveness is derived from app-db

A spawned actor's liveness IS the presence of its snapshot at [:rf/runtime :machines :snapshots <actor-id>] in the frame's (revertible) value — nothing else. Spawn and destroy are pure app-db writes: spawn installs the snapshot (stamping the revertible TYPE reference under :rf/machine-type, per §Reserved snapshot-internal keys) and the spawn-registry slot; destroy removes them. There is no per-instance event-handler registration — the spawn does NOT add an entry to any registrar, and the destroy clears none.

When a dispatch targets an <actor-id> for which no handler is registered, the runtime lazily resolves the actor's handler from app-db: it reads the live snapshot, recovers the actor's TYPE (the :machine-id keyword names a registered machine — the TYPE is registered like a singleton and outlives every instance — or, for an inline :definition spawn, the spec rides the snapshot directly), and routes the event to that TYPE's handler with the <actor-id> context. The address form is [<actor-id> <event>], exactly as for a singleton. If no live snapshot exists for the actor-id, the dispatch is a genuine :rf.error/no-such-handler — correct, because the actor is not alive in this frame value.

Why this is load-bearing — it closes the Goal 2 — Frame state revertibility leak. Because liveness is a pure function of app-db, a frame revert (restore-epoch / undo / SSR hydration) reverts it perfectly and atomically with the rest of the state:

  • Rewind past a spawn → the snapshot vanishes with the rest of app-db; there is no orphaned handler left behind, because none was ever registered. A dispatch to the gone actor is a clean :rf.error/no-such-handler.
  • Rewind past a destroy → the snapshot comes back with the rest of app-db, and a dispatch to the actor resolves again through the lazy resolver — its liveness reverted with its snapshot, with zero registrar drift.

This is the design finally keeping its own promise. §Where snapshots live warns that "a parallel ActorRef registry … would put machine state outside the frame's value and break the goal." A per-instance handler registration IS exactly such a parallel registry — it would hold an actor's liveness outside the frame value. Earlier drafts of this section relaxed to a per-instance registrar entry as a v1 expedient (the warned-against anti-pattern, one level up, for liveness); the lazy-resolver eliminates it entirely. Liveness now lives where state lives — inside the revertible frame value.

Symmetry between singleton and spawned:

id form snapshot location liveness
Singleton :drawer/editor (explicit) [:rf/runtime :machines :snapshots :drawer/editor] a registrar entry registered at boot via reg-event-fx; outlives any one frame's value
Spawned actor :request/protocol#42 (gensym'd) [:rf/runtime :machines :snapshots :request/protocol#42] the SNAPSHOT's presence — no per-instance registrar entry; resolved on dispatch from the snapshot's :rf/machine-type; reverts with the frame value

Both are addressable by dispatch ([<id> <event>]). Both readable through the framework-registered :rf/machine sub (per §Subscribing to machines via sub-machine) — the actor-id is just the argument: @(rf/sub-machine actor-id). A singleton appears in (registrations :event); a spawned actor does not (its liveness is its snapshot) — enumerate live actors via [:rf/runtime :machines :snapshots] instead, per §Querying machines.

Spawn lifecycle — ordering

The spawn surface is composite: :on-spawn, :rf.machine/spawn, the synthetic [:rf.machine.spawn/spawned], :start, and the spawned actor's initial-:entry cascade all participate. The individual pieces are spec'd in their own subsections; this section enumerates the strict ordering between them — what fires when, against what context, between the moment a parent state with :spawn is entered and the moment the spawned child processes its first user event.

Two distinct "spawn" surfaces, easy to conflate:

  • :on-spawn — advisory observation hook. Declared inside the parent's :spawn / :spawn-all spec (or on a hand-emitted :rf.machine/spawn). Runs inside the transition reducer at allocate-time, invoked with {:data <parent's :data> :id <freshly-allocated spawned-id>} — the unified context-map (per §Path conventions in machine bodies). NOT a child-side event. Its return value is DROPPED — the runtime does not patch it back into the snapshot. It exists for side-channel observation (logging, mirroring the id into instrumentation); the runtime tracks the id in the spawn-registry at [:rf/runtime :machines :spawned <parent-id> <invoke-id>] regardless of whether :on-spawn is declared. To address the child user-side, use one of the three working mechanisms in §Recording the spawned id user-side.
  • [:rf.machine.spawn/spawned] — synthetic event dispatched into the new child. Emitted by the :rf.machine/spawn fx handler only when the spawn args omit :start, so generic child machines have a kick-off event to handle. Reaches the child as its first event, ahead of the initial-:entry cascade only in resolution order — see step 6 below. Per §Synthetic [:rf.machine.spawn/spawned] on spawn.

Ordering — singleton parent invoking a child

For a parent state with :spawn {:machine-id :child …} (or for a hand-emitted :rf.machine/spawn from an action), the runtime fires the following steps in order. Steps 1–4 happen inside the parent's drain; steps 5–8 happen inside the child's drain (a separate event-handler boundary).

  1. Parent enters the :spawn-bearing state. The transition's entry cascade reaches the state-node; the desugared :rf.spawn/spawn-<state> action (per §Desugaring rules) is appended to the cascade's action queue.
  2. :on-spawn advisory hook fires. The pure spawn-id allocator picks the next <id-prefix>#<n> against :rf/spawn-counter at the parent's snapshot root, then writes the spawn-registry slot [:rf/runtime :machines :spawned <parent-id> <invoke-id>] (the authoritative user-readable home of the id). The :on-spawn callback (when declared) is then invoked with {:data <parent's :data> :id <id>} for side-channel observation; its return value is dropped and no fx is emitted by :on-spawn itself.
  3. :rf.machine/spawn fx is emitted into the parent transition's :fx vector with the allocated spawned-id, the resolved child :data, and (for declarative :spawn) the stamped :rf/parent-id / :rf/spawn-id keys.
  4. Parent's drain commits. The parent's post-action snapshot is written to [:rf/runtime :machines :snapshots <parent-id>] and the :fx vector drains through the fx pipeline. Up to this point the child does NOT exist.
  5. :rf.machine/spawn fx handler runs. The child's spec is resolved (registered :machine-id or inline :definition); synthesise-initial-snapshot produces the child's initial snapshot with :rf/bootstrap-pending? true, the runtime-stamped :data keys (:rf/self-id, :rf/parent-id, :rf/spawn-id — per §Runtime stamps), the TYPE reference :rf/machine-type, and the user-supplied initial :data merged on top; the snapshot is installed at [:rf/runtime :machines :snapshots <spawned-id>]. This is a pure app-db write — NO per-instance handler is registered (the actor's liveness IS the snapshot; per §Liveness is derived from app-db). The runtime spawn-registry slot at [:rf/runtime :machines :spawned <parent-id> <invoke-id>] is written for the declarative-:spawn case.
  6. Child's first event is dispatched[<spawned-id> <:start arg>] when the spawn args carried :start, otherwise the synthetic [<spawned-id> [:rf.machine.spawn/spawned]]. The two paths are mutually exclusive; the child receives exactly one of the two as its first event, never both.
  7. Child's initial-entry cascade fires (per §Initial-state :entry fires on machine creation (start)). For a flat child the single initial state's :entry runs; for a compound child every :entry along the initial chain runs shallowest-first. This cascade runs before the first event's :on lookup, so :entry-emitted :fx is concatenated ahead of the first event's transition fx. :rf/bootstrap-pending? is cleared by the same drain.
  8. Child processes the first event. The event vector arrived in step 6 is now resolved through the child's :on map (deepest-wins per §Transition resolution). For the synthetic [:rf.machine.spawn/spawned] path with no matching handler the snapshot is unchanged — and because the kick-off id lives in the reserved :rf/* namespace it is exempt from the unhandled-no-op: no :rf.machine.event/unhandled-no-op trace fires (the reserved-:rf/* lifecycle carve-out per §Transition resolution). The kick-off is framework init dispatched into every generic child, not an unknown user event the author forgot to handle; severity is benign either way (nothing throws), this is purely about not mislabelling framework lifecycle traffic as a missed user event.

The same skeleton applies to :spawn-all's N children (per §Spawn-and-join via :spawn-all) with steps 2–5 fanning out once per child and a single join-bookkeeping write at [:rf/runtime :machines :spawned <parent-id> <invoke-all-id>].

Worked walkthrough

;; Parent
{:authenticating
 {:spawn {:machine-id :auth-flow
           :data       (fn [{snap :snapshot}] {:credentials (-> snap :data :form)})
           :system-id  :auth-actor                              ;; address the child by name (working)
           :on-spawn   (fn [{id :id}] (println "spawned auth flow" id))} ;; observation only — return dropped
  :on     {:auth/succeeded :authenticated
           :auth/failed    :idle}}}

;; Child (registered separately)
(rf/reg-machine :auth-flow
  {:initial :running
   :data    {}
   :states {:running {:entry :fire-request
                      :on    {:server-ok {:target :done}}}
            :done    {:final? true :output-key :token}}
   :actions {:fire-request (fn [{data :data}] {:fx [[:http/post ]]})}})

Trace of a [:submit] event landing on the parent in :idle:

  1. Parent transitions :idle → :authenticating. Entry cascade reaches :authenticating.
  2. Allocator picks :auth-flow#0 and writes the spawn-registry slot [:rf/runtime :machines :spawned :login [:authenticating]]:auth-flow#0; the :system-id :auth-actor binding resolves to it via (rf/machine-by-system-id :auth-actor). The :on-spawn hook then fires for observation (its return is dropped).
  3. Parent's :fx accumulates [:rf.machine/spawn {:machine-id :auth-flow :rf/parent-id :login :rf/spawn-id [:authenticating] …}].
  4. Parent commits — [:rf/runtime :machines :snapshots :login] updated; the spawn fx drains.
  5. Spawn fx synthesises :auth-flow#0's initial snapshot at [:rf/runtime :machines :snapshots :auth-flow#0] with :state :running, :data {:rf/self-id :auth-flow#0 :rf/parent-id :login :rf/spawn-id [:authenticating] :credentials …}, :rf/bootstrap-pending? true; the spawn-registry slot at [:rf/runtime :machines :spawned :login [:authenticating]] is written to :auth-flow#0.
  6. Spawn-args lacked :start, so the synthetic [:auth-flow#0 [:rf.machine.spawn/spawned]] is dispatched.
  7. The child's drain runs the initial-entry cascade: :running's :entry :fire-request action emits the HTTP fx. :rf/bootstrap-pending? clears.
  8. [:rf.machine.spawn/spawned] resolves through :running's :on map — no match. The snapshot is unchanged and, because the kick-off id is reserved-:rf/* framework lifecycle traffic, no :rf.machine.event/unhandled-no-op fires (the reserved-:rf/* carve-out per §Transition resolution) — the kick-off is framework init, not an unknown user event.

The contract on the trace stream — every step above corresponds to a distinct externally-observable event:

Step Trace
1 :rf.machine/transition (parent)
2 (no separate trace — :on-spawn runs inside the transition reducer)
3 (the fx is in the parent's :fx vector, visible as :rf/fx once the drain emits it)
4 :rf.machine.lifecycle/commit (parent)
5 :rf.machine.spawn/spawned (fx-substrate observation — the spawn fx ran) then :rf.machine.lifecycle/spawned (child; registrar-substrate observation — the actor's snapshot landed in the registrar) — the two-axis pair per 009 §Two-axis machine observation
6 :rf/event (the synthetic kick-off)
7 :rf.machine.lifecycle/bootstrap (child) + per-action traces
8 (no trace — the :rf.machine.spawn/spawned kick-off is reserved-:rf/* framework lifecycle traffic, exempt from the unhandled-no-op per the reserved-:rf/* carve-out; the snapshot is unchanged)

Why this matters

The ordering is what lets two patterns compose without surprises:

  • Initial-entry :entry actions can read the runtime-stamped :data keys. Per step 5, the spawn-fx writes :rf/self-id / :rf/parent-id / :rf/spawn-id into the child's :data BEFORE step 7 fires the :entry cascade — so an :entry action can (get data :rf/parent-id) to address its parent without the parent having to thread the id through any other mechanism.
  • :start is for handing the child a one-shot event payload. When the child needs a specific first event (e.g. [:begin "/some/url"]), the parent supplies :start; when the child knows its job from initial :data alone, the parent omits :start and the synthetic [:rf.machine.spawn/spawned] is just a benign kick-off — the real work happens inside the initial-:entry cascade. New code prefers :entry over :on :rf.machine.spawn/spawned (per the note in the synthetic-event subsection).

Spawning from inside an action (the common case)

:action (fn [{[_ url] :event}]
          {:fx [[:rf.machine/spawn {:machine-id :request/protocol
                                    :id-prefix  :request/protocol
                                    :data       {:url url}
                                    :system-id  :pending-request   ;; address the child by name
                                    :start      [:begin]}]]})

After this action the actor is reachable by its :system-id. Subsequent transitions can [:fx [[:dispatch-to-system :pending-request [:retry]]]] (or [:dispatch [(rf/machine-by-system-id :pending-request) [:retry]]]). The :on-spawn callback is not the place to capture the id — its return is dropped (see §Recording the spawned id user-side).

Spawn-spec keys

key purpose required?
:machine-id or :definition which machine to instantiate (registered id, or inline spec map) one of these
:id-prefix base for the gensym'd actor id (:request/protocol#42) optional; defaults to :machine-id
:data initial data for the new machine (overrides definition's default) optional
:on-spawn (fn [{:keys [data id]}] _) — advisory callback fired with the spawned id; return is ignored (runtime tracks the id at [:rf/runtime :machines :spawned <parent> <invoke-id>]). optional for any spawn; ignored for top-level boot-time spawns
:start event vector dispatched to the new actor immediately after spawn optional
:system-id bind the spawned actor to a per-frame name in the [:rf/runtime :machines :system-ids] reverse index; lookup with (rf/machine-by-system-id sid). See §Named addressing via :system-id. optional

The spawned actor's snapshot lives at [:rf/runtime :machines :snapshots <gensym'd-id>] — the runtime owns the location, the spawn-spec only declares the id-prefix. See §Where snapshots live and Spec-Schemas §:rf.fx/spawn-args.

Spawn-id allocator — counter location

Spawn-id allocation is pure (no global counter atom, no gensym), and the integer counter the allocator increments lives in a different location depending on the spawn surface. Both locations are pattern contract — a conformant port MUST mirror the split, because it is what keeps Goal 3 — frame-state revertibility intact across the two surfaces.

spawn surface counter location revert behaviour
Declarative :spawn / :spawn-all from a parent state inside the parent machine's snapshot at [:rf/spawn-counter <id-prefix>] — i.e. snapshot-local to the parent a frame revert that restores the parent's snapshot also restores the parent's counter; re-running the same cascade allocates the same ids
Hand-emitted :rf.machine/spawn fx (from an event handler's :fx, no parent machine) inside the framework's reserved root at [:rf/runtime :machines :spawn-counter <id-prefix>] — i.e. frame-app-db-local under the single :rf/runtime container a frame revert that restores app-db restores the counter; re-running the same drain allocates the same ids

Two spawn-counters, one rule. The two rows above are two locations, not two policies — read them as a single invariant: the spawn-id counter lives in whatever state reverts atomically with the thing that allocated the id. A declarative :spawn is allocated inside a parent's transition reducer, so its counter rides the parent's snapshot; a hand-emitted :rf.machine/spawn is allocated inside a top-level drain, so its counter rides frame-app-db. Both choices serve the same goal (re-running a reverted cascade/drain re-allocates the same ids); neither is a special case to memorise.

The two-tier split is not implementation discretion. It falls out of the requirement that revertibility round-trips through the same allocation site that produced the ids in the first place — declarative spawns are part of a parent's transition reducer (so their counter belongs to the parent's snapshot, which is what reverts atomically with the cascade), whereas hand-emitted spawns are part of a top-level drain (so their counter belongs to frame-app-db, which is what reverts atomically with the drain). Putting either counter outside the snapshot it composes with — e.g. a global counter, or an out-of-band atom — would break the round-trip: a revert would restore the snapshot but leave the counter at its post-revert value, and the next spawn would allocate a different id than the original, drifting the trace stream and the spawn-registry slot.

Per-prefix integer counts are stamped as {} at snapshot synthesis and at frame-app-db construction; both maps persist across pr-str / read-string so the allocator survives a full snapshot round-trip. The id form (<id-prefix>#<n> keyword) is fixed per §Spawn id format. See also Conventions §Reserved app-db keys — :rf/spawn-counter and Cross-Spec-Interactions §1 — Frame disposal.

Runtime stamps on the spawned actor's :data

Per the runtime stamps three framework-reserved keys into every spawned actor's initial :data map so the actor can address its parent and itself at action-call time without the parent having to thread that information through manually:

key value when present
:rf/self-id the spawned actor's own address (e.g. :request/protocol#42) always
:rf/parent-id the parent machine's registration-id when the spawn carries :rf/parent-id (the declarative :spawn / :spawn-all desugar path)
:rf/spawn-id the absolute prefix-path of the parent's :spawn-bearing state node same as :rf/parent-id

Per §Path conventions in machine bodies, the :rf/* namespace inside :data is reserved for runtime-managed keys; user code does not write under it. The actor reads these as ordinary :data lookups inside its actions:

:dispatch-done (fn [data _]
                 (when-let [parent-id (:rf/parent-id data)]
                   {:fx [[:dispatch [parent-id [:done (:result data)]]]]}))

Imperative :rf.machine/spawn from a user's :fx (the rare boot-time form per §Top-level boot-time spawn) doesn't carry :rf/parent-id / :rf/spawn-id, so only :rf/self-id is stamped. That's the right shape — there's no parent in that case.

Synthetic [:rf.machine.spawn/spawned] on spawn

Per — when [:rf.machine/spawn ...] does NOT carry an explicit :start event, the runtime dispatches a synthetic [<spawned-id> [:rf.machine.spawn/spawned]] to the new actor as its first event.

Note. Per §Initial-state :entry fires on machine creation (start), :entry fires on machine creation, so :entry :fire-request is the canonical shape. The synthetic [:rf.machine.spawn/spawned] event still flows for machines that declare :on :rf.machine.spawn/spawned ..., but new code should prefer the :entry form.

;; Canonical shape:
:requesting {:entry :fire-request}

;; Alternative (still supported, but not the canonical shape):
:requesting {:on {:rf.machine.spawn/spawned {:action :fire-request}}}

Machines that don't handle :rf.machine.spawn/spawned simply have no clause for it — the event walks the leaf→root resolution chain, finds no match, and the snapshot is unchanged. Because the kick-off id is reserved-:rf/* framework lifecycle traffic, it is exempt from the unhandled-no-op: no :rf.machine.event/unhandled-no-op fires (the reserved-:rf/* carve-out; per §Transition resolution — deepest-wins with parent fallthrough). The kick-off is framework init dispatched into every generic child, not an unknown user event the author forgot to handle.

When the spawn DOES carry :start, the runtime dispatches [<spawned-id> <start>] instead — the existing behaviour, unchanged. The two paths are mutually exclusive; an actor receives one of :rf.machine.spawn/spawned OR the user's :start, never both. In both cases the initial-state :entry cascade runs BEFORE the first event's :on lookup, so :entry actions on the initial state fire regardless of which kick-off mode the spawn used.

:source classification — :machine-spawn

The spawn fx (:rf.machine/spawn) dispatches the spawned actor's first event — either the user-supplied :start event or the synthetic [:rf.machine.spawn/spawned] — into the new actor. That dispatch stamps :source :machine-spawn on the dispatch envelope so the Epoch panel's DISPATCH step renders "from machine spawn" rather than :unknown (the residual default per rf2-hxj0d) or :fx-dispatch (which would be the default if the spawn fx routed through :dispatch). The naming — :machine-spawn — mirrors the spec's term so the operator-facing label greps back to this section. Per rf2-ejtpd. (Per rf2-1ve9h the prior parallel :rf/dispatch-origin :internal tag was collapsed into :source:machine-spawn is now the single functional-origin discriminator.)

Top-level boot-time spawn (rare)

The canonical surface is the [:rf.machine/spawn ...] fx — used inside an event handler's :fx. From outside a handler (e.g. boot-time), wrap the spawn in a one-shot bootstrap event:

(rf/reg-event-fx
  :app/spawn-request-protocol
  (fn [_ [_ url]]
    {:fx [[:rf.machine/spawn
           {:definition request-protocol           ;; or :machine-id if reusing a registered definition
            :id-prefix  :request/protocol           ;; → :request/protocol#42
            :data       {:url url :attempt 0}
            :system-id  :request-id}]]}))            ;; address it later by name (an :on-spawn return would be dropped)

(rf/dispatch-sync [:app/spawn-request-protocol "/foo"])

;; snapshot lives at [:rf/runtime :machines :snapshots :request/protocol#42] in the active frame's app-db.

;; address it
(rf/dispatch [actor-id [:retry]])
(rf/dispatch [actor-id [:cancel]])

;; destroy — emit the canonical destroy fx from a handler
(rf/reg-event-fx
  :app/destroy-request-protocol
  (fn [_ [_ actor-id]]
    {:fx [[:rf.machine/destroy actor-id]]}))
;; Internally: run :exit action, dissoc the snapshot at [:rf/runtime :machines :snapshots actor-id],
;; clear-event actor-id. (No per-machine sub to clear — reads go through the
;; framework-registered :rf/machine sub, parameterised on actor-id.)

(The v1 public fns spawn-machine / destroy-machine are dropped — see MIGRATION.md §M-26.)

Destroy is silent-idempotent

Destroying an already-destroyed actor is a deliberate silent no-op (aligned with the XState convention). The actor's lifecycle has exactly one observable transition (Active → Stopped); subsequent destroy attempts emit NO :rf.machine/destroyed trace, perform NO teardown work, and raise NO error. This holds uniformly across the destroy surface:

  • Explicit destroy after auto-destroy. A child reaches :final? and auto-destroys (per §Final states D4); a subsequent [:rf.machine/destroy <id>] fx on the same id is a silent no-op.
  • Double-explicit destroy in the same cascade. Two [:rf.machine/destroy <id>] entries in the same :fx vector — or a user action emitting the fx for an actor whose declarative-:spawn exit cascade has already fired — collapse to one observable destroy and one trace.
  • Tracked-form destroy with no spawn-slot entry AND no snapshot. A [:rf.machine/destroy {:rf/parent-id … :rf/spawn-id …}] whose [:rf/runtime :machines :spawned <parent-id> <invoke-id>] slot is already cleared AND whose resolved actor has no snapshot is a silent no-op (both have been atomically cleared by an earlier destroy).

The liveness probe distinguishes already-destroyed (the actor was alive, the teardown projection ran, the registrar slot was cleared, the spawn-order entry was forgotten) from alive-but-unmaterialised-snapshot (the actor IS alive in this drain — spec-less spawn, or a back-to-back spawn-then-destroy in the same :fx vector — but its snapshot was never installed at [:rf/runtime :machines :snapshots <actor-id>]). Snapshot-presence alone is not the right signal: a spec-less spawn (the runtime allocates the spawn-slot even when :machine-id resolves to no registered spec — e.g. an SSR / platform-gated build that registered the parent but not the child) never installs a snapshot, yet its destroy still owns legitimate cleanup work (spawn-order/forget + the observability trace). The runtime treats an actor as live when ANY of the following holds: - the actor's event handler is still registered at <actor-id>, - a snapshot exists at [:rf/runtime :machines :snapshots <actor-id>], - the per-frame spawn-order channel still tracks <actor-id>, or - (tracked-form only) the spawn-slot at [:rf/runtime :machines :spawned <parent-id> <invoke-id>] still resolves.

An already-destroyed actor has all of these gone, atomically — the unified teardown projection (per §Final states D4) dissocs the snapshot, clears the spawn-slot, and releases the [:rf/runtime :machines :system-ids] reverse-index entry; registrar/unregister! clears the handler; spawn-order/forget! clears the channel entry. The destroy fx handler probes these liveness signals before any side effect (trace emit, exit cascade, HTTP abort, registrar unregister, spawn-order forget) and returns early when and only when the actor was previously alive and is now fully gone.

Observability is unchanged: the original :rf.machine/destroyed (with :reason :explicit for cascade-driven destroys, :reason :rf.machine/finished for auto-destroy on :final? per §Final states D6) IS the destroy signal. No second event fires for the no-op attempts, and no :rf.warning/destroy-of-finished-actor is emitted — silence is the contract.

Spawning multiple, dynamic counts

Multiple [:rf.machine/spawn ...] entries in :fx work independently; each fires its (advisory) :on-spawn hook with the freshly-allocated id. For dynamic-count spawning, build the :fx vector with mapv:

:action (fn [{[_ jobs] :event}]
          {:fx (mapv (fn [job]
                       [:rf.machine/spawn {:machine-id :worker
                                           :data       job}])
                     jobs)})
;; → each worker's id is reachable from the spawn-registry; to collect them
;;   into the parent's :data, emit one :rf.machine/update-snapshot after the
;;   spawns drain (the ids live at [:rf/runtime :machines :spawn-counter ...]
;;   deterministically), or address each worker by a per-job :system-id.

To record the ids in the parent's :data, do NOT reach for :on-spawn — its return is dropped. Use :rf.machine/update-snapshot from a regular :action's :fx, or give each spawn a distinct :system-id and resolve by name. See §Recording the spawned id user-side.

What spawning gives for free

  • Inspection. (registrations :event) lists every live actor. Filter by :rf/machine? metadata.
  • Tracing. Every message to an actor is a normal :event trace. Lifecycle is :registry/handler-{registered,cleared}.
  • Errors. Sending to a destroyed actor → :rf.error/no-such-handler. Already categorised, already recoverable.
  • Hot-reload. Live spawned instances pick up new table interpretations on next event.
  • Cross-machine messaging. Parent → child is [:fx [[:dispatch [child-id [:event]]]]]. Child → parent is the same. No sendTo / sendParent distinction — dispatch already addresses any id.
  • :raise lowers to self-dispatch with atomic semantics. [:raise [:event]][:fx [[:dispatch [<self-id> [:event]]]]] with "processed before commit." The former is sugar.

Named addressing via :system-id

A spawn whose :system-id key is supplied also binds a name in the per-frame [:rf/runtime :machines :system-ids] reverse index. Users (and other machines) can then look up the spawned actor by that name, without having to thread the gensym'd id through their own :data. The mechanism is opt-in and orthogonal to gensym'd ids — it sits alongside the existing addressing-by-id, never replaces it.

;; Imperative spawn (action :fx) with a :system-id binding.
:action (fn [_]
          {:fx [[:rf.machine/spawn {:machine-id :request/protocol
                                    :system-id  :primary-request    ;; bind the name
                                    :data       {:url "/api/foo"}
                                    :start      [:begin]}]]})

;; The same :system-id key works on declarative :spawn:
{:loading
 {:spawn {:machine-id :request/protocol
           :system-id  :primary-request
           :data       (fn [{snap :snapshot}] {:url (-> snap :data :endpoint)})}}}

;; Anywhere in the same frame:
(rf/machine-by-system-id :primary-request)
;; → :request/protocol#42 (the gensym'd id)

The mapping lives at [:rf/runtime :machines :system-ids <name>] in the spawning frame's app-db — same place the snapshot lives, so the reverse index inherits frame revertibility for free (the index walks back along with the rest of app-db).

Lifecycle.

  • On spawn, the runtime writes [:rf/runtime :machines :system-ids <name>] = <gensym'd-id> and emits :rf.machine/system-id-bound.
  • On destroy (whether by :spawn exit cascade or hand-emitted [:rf.machine/destroy actor-id]), the runtime clears the slot AND emits :rf.machine/system-id-released.
  • A spawn under an already-bound name rebinds (last-write-wins) and emits :rf.error/system-id-collision so observers can see the displacement. The previously-bound machine's snapshot is NOT auto-destroyed by the rebind; it stays at its [:rf/runtime :machines :snapshots <id>] slot, just unnamed. (Symmetric with reg-event-fx re-registration: replacing a handler doesn't cancel any in-flight work that addressed the previous fn; it just means the next named dispatch routes to the new one.)

:system-id is orthogonal to :spawn-id.

  • :spawn-id is a per-state singleton actor id — the machine-id of the spawned actor is fixed by name (no gensym).
  • :system-id is a frame-level reverse index that resolves to whichever spawned actor currently owns the name.

A spawn may declare both: :spawn-id fixes the actor-id (no gensym), and :system-id registers a separate name in the frame's reverse index. Most uses pick one or the other.

Cross-machine messaging by name

The standard cross-machine pattern remains [:fx [[:dispatch [<other-id> [:event]]]]]dispatch already addresses any registered id. With :system-id bound, the addressing call site becomes a name lookup:

;; Inside a machine action's :fx — dispatch by name
:action (fn [_]
          {:fx [[:dispatch [(rf/machine-by-system-id :primary-request)
                            [:cancel]]]]})

;; Sugar — dispatches via the lookup, no-ops when the name is unbound:
:action (fn [_]
          {:fx [[:dispatch-to-system :primary-request [:cancel]]]})

The sender doesn't have to capture the gensym'd id at the spawn site, doesn't have to carry it through :data, doesn't even have to be the spawning machine — anything in the frame that knows the name can address the actor.

The pattern composes naturally with the standard reply convention (§Reply patterns): include the reply event in the request, addressed by name on the request side, by id on the reply side (so the reply lands in a specific spawned correlator, not whichever machine currently owns the name).

Declarative :spawn

:spawn on a state node is declarative sugar for "spawn this child actor on entry; destroy it on exit." The child's lifetime is bound to the state's lifetime: while the machine is in this state, the child runs; when the machine leaves the state (by any transition, including a parent-level cascade), the child is destroyed.

:spawn is registration-time sugar. make-machine-handler walks the spec at construction time and rewrites every :spawn slot into entry/exit actions emitting :rf.machine/spawn and :rf.machine/destroy fx. The runtime sees only the desugared form — no new mechanics, no new lifecycle event, no new error category.

The pattern

{:loading
 {:spawn {:machine-id :request/protocol
           :data       {:url "/api/foo"}
           :system-id  :loader              ;; address the child by name (its id is dropped from :on-spawn)
           :start      [:begin]}
  :on     {:succeeded {:target :loaded}
           :failed    {:target :error}}}}

While in :loading, an actor of :request/protocol exists at [:rf/runtime :machines :snapshots <gensym'd-id>], addressable through the runtime-owned registry at [:rf/runtime :machines :spawned <parent-machine-id> [:loading]] and, with the :system-id :loader binding above, by name via (rf/machine-by-system-id :loader). On any transition out of :loading, the actor is destroyed and its snapshot disappears — the runtime locates it via the registry slot, never requiring the user to have written the id under any specific :data key (an :on-spawn callback could not do so anyway — its return is dropped).

Spec-spec keys

The map under :spawn accepts the following keys:

key purpose required?
:machine-id or :definition which machine to spawn (registered id, or inline transition table) exactly one of these
:data initial data for the child — literal map or (fn [{:keys [snapshot event]}] data) (per — unified context-map) optional
:id-prefix base for the gensym'd actor id (:request/protocol#42) optional; defaults to :machine-id
:on-spawn (fn [{:keys [data id]}] _) — advisory callback fired with the spawned id; return is ignored (runtime tracks the id at [:rf/runtime :machines :spawned <parent> <invoke-id>]) optional
:on-done (fn [{:keys [data result]}] new-data) — fires when the child enters a :final? state; result is the child's :data slot named by the final state's :output-key (or nil if no :output-key declared) — see §Final states. Returns the parent's new :data map. optional
:start event vector dispatched to the newborn after spawn optional
:spawn-id explicit id instead of gensym (useful for tests / per-state singleton actors) optional

The keys mirror §Spawn-spec keys, with two additions:

  • :data admits a function form (fn [{:keys [snapshot event]}] data) so the initial data can depend on the snapshot + the triggering event at the moment of entry — the snapshot is the post-action value (the transition's :action has already run, so any :data writes the action made are visible). Per the callback receives the unified context-map.
  • :spawn-id is an explicit alternative to :id-prefix + gensym — useful when a state should host exactly one actor with a known id (no need to record the id in the parent's :data because it's already a known constant).

Wall-clock timeouts: use the parent state's :after slot. Earlier drafts of this spec carried a :timeout-ms slot on :spawn / :spawn-all for "the whole spawned actor must terminate within N ms (spanning retries)." That slot is dropped in favour of the canonical :after primitive on the parent state — :after is one mechanism, not two. Per §Whichever fires first wins, an :after firing on the parent state exits the state and the standard exit cascade destroys the in-flight :spawnd child. The migration recipe is mechanical: lift the :timeout-ms value into the :spawn-bearing state's :after map, with a transition that exits the state to a "timeout" target. See MIGRATION §M-44.

Path convention. The :on-spawn callback receives the unified context-map {:data <parent's :data> :id <spawned-id>} — it reads the parent's :data but, unlike :guard / :action, its return value is dropped: :on-spawn cannot write the parent's :data. The runtime does NOT patch any result back into the snapshot. Use :on-spawn for side-channel observation only (logging, mirroring the id into instrumentation). To record the id in the parent's :data, see §Recording the spawned id user-side.

:on-spawn is purely advisory — a side-channel observation hook. Per (Option A revised), the runtime tracks each declarative-:spawn spawn-id at the reserved app-db slot [:rf/runtime :machines :spawned <parent-machine-id> <invoke-id>] (where <invoke-id> is the absolute prefix-path of the :spawn-bearing state node) regardless of whether :on-spawn is declared. The :on-spawn callback runs only so apps can observe the spawn (logging, instrumentation) — it does NOT record the id anywhere itself (its return is dropped), and the runtime never depends on it for destroy-side resolution. Apps can omit :on-spawn entirely; the parent's :exit cascade still tears down the spawned child via the runtime registry.

Use :on-spawn ONLY for side-channel observation. Per audit-of-audits state-machines #7 — the callback is the canonical place to log spawn events or mirror the new id into instrumentation. It is NOT load-bearing for destroy-side lifecycle, which the runtime owns via the [:rf/runtime :machines :spawned <parent-id> <invoke-id>] registry. Treat :on-spawn as the convenient observer hook for "the spawn just happened, here's the id" — not as a step the framework needs you to perform for correctness, and not as a way to write the parent's :data (its return is dropped — see below).

:on-spawn is purely advisory: the runtime invokes the callback with {:data ... :id <spawned-id>} (per the unified context-map contract) and DROPS the return value entirely. The parent's :data cannot be mutated by :on-spawn. The runtime owns destroy-side resolution via the [:rf/runtime :machines :spawned <parent-id> <invoke-id>] registry.

Recording the spawned id user-side

:on-spawn's return is dropped, so a callback like (assoc data :pending id) is a no-op — (:pending data) stays nil forever, and in a dev build the runtime emits :rf.warning/on-spawn-return-ignored to flag the dropped value (per rf2-dtth6). There are three working mechanisms for getting a user-side handle on the spawned id; pick one:

  1. :system-id (recommended). Declare :system-id :my-name on the :spawn / :rf.machine/spawn spec; resolve anywhere in the frame with (rf/machine-by-system-id :my-name) or dispatch with [:dispatch-to-system :my-name [...]]. See §Named addressing via :system-id.
  2. Read the runtime registry slot. The id is always at [:rf/runtime :machines :spawned <parent-id> <invoke-id>] (<invoke-id> is the absolute prefix-path of the :spawn-bearing state) — read it directly when you have the parent-id and state path.
  3. :rf.machine/update-snapshot. From a regular :action's :fx vector, emit [:rf.machine/update-snapshot {:rf/machine-id <id> :rf/patch {:data {...}}}] to write the id (read from the registry slot) into the parent's :data atomically. See the snapshot-level escape hatch in §Path conventions in machine bodies.

Desugaring rules

make-machine-handler walks every state node at construction time. For each :spawn-bearing state, it:

  1. Composes an :rf.spawn/spawn-<state> registered action that emits a :rf.machine/spawn fx whose args are the :spawn spec, with :data materialised (call the fn if :data is a fn, else use the literal). The runtime stamps :rf/parent-id (the parent machine's registration-id) and :rf/spawn-id (the absolute prefix-path of the :spawn-bearing state node) onto the spawn args; the :rf.machine/spawn fx handler binds the spawned id at [:rf/runtime :machines :spawned <parent-id> <invoke-id>] in the frame's app-db.
  2. Composes an :rf.spawn/destroy-<state> registered action that emits a :rf.machine/destroy fx whose args carry the same {:rf/parent-id ... :rf/spawn-id ...}. The fx handler reads the spawned id back from [:rf/runtime :machines :spawned <parent-id> <invoke-id>] at call time and tears down whatever id is currently bound there. (For :spawn-id literals — the explicit-id case — the runtime uses that id directly; the registry slot still binds it for symmetry.)
  3. Wires the composed actions into the state's :entry and :exit slots, after any user-supplied :entry / :exit (see §Composition with explicit :entry / :exit).

The runtime-owned spawn registry at [:rf/runtime :machines :spawned ...] is sibling to [:rf/runtime :machines :system-ids] (per §Named addressing via :system-id) — same lazy-allocation invariant (absent until the first declarative-:spawn spawn), same per-frame isolation (each frame's app-db carries its own slot), same revertibility (the slot walks back atomically with app-db on a frame revert).

Before / after:

;; user writes (declarative :spawn):
{:loading
 {:spawn {:machine-id :request/protocol
           :data       (fn [{snap :snapshot}] {:url (-> snap :data :endpoint)})
           :system-id  :loader
           :start      [:begin]}
  :on     {:succeeded :loaded
           :failed    :error}}}

;; make-machine-handler rewrites to (runtime sees this):
{:loading
 {:entry (fn [{data :data}]
           {:fx [[:rf.machine/spawn {:machine-id   :request/protocol
                                     :id-prefix    :request/protocol
                                     :data         {:url (:endpoint data)}
                                     :system-id    :loader
                                     :start        [:begin]
                                     ;; Stamped by the runtime — addresses the
                                     ;; runtime-owned spawn registry slot at
                                     ;; [:rf/runtime :machines :spawned <parent-id> <invoke-id>].
                                     :rf/parent-id <parent-machine-id>
                                     :rf/spawn-id [:loading]}]]})
  :exit  (fn [_]
           ;; (Option A revised) — the destroy fx no longer
           ;; reads the actor id from `:data`. The fx handler resolves
           ;; the id from [:rf/runtime :machines :spawned <parent-id> <invoke-id>] in the
           ;; frame's app-db at call time.
           {:fx [[:rf.machine/destroy {:rf/parent-id <parent-machine-id>
                                       :rf/spawn-id [:loading]}]]})
  :on    {:succeeded :loaded
          :failed    :error}}}

From outside, a :spawn-using machine is indistinguishable from one that wrote the entry/exit by hand — and the runtime never requires the user to record the spawned id under any particular :data slot (it could not be recorded from :on-spawn anyway — that return is dropped). The pure-factory invariant on make-machine-handler is preserved — no global state, no new registry kind, no new lifecycle hook (the [:rf/runtime :machines :spawned ...] slot lives inside app-db per Conventions §Reserved app-db keys; not a separate registry).

Spec-as-data caveat for :spawn / :spawn-all. The Principles §Data is code invariant ("what you write IS what runs") holds at the user-visible boundary: machine-meta returns the user-written spec form (the registrar stores the user-supplied map verbatim — see §Querying machines), and the conformance harness, the migration agent, and tools that read registered specs all see the same shape the author wrote. Where the invariant is fudged is the runtime spec value threaded through apply-transition-once: make-machine-handler walks the user spec at construction time and rewrites every :spawn slot into the :entry / :exit action pair shown above. A debugger that prints the runtime spec record sees the desugared form, not the literal :spawn map. The two surfaces are deliberately split: the spec-as-data invariant covers what users wrote and what tools read back via machine-meta; the runtime-internal form is an implementation detail of the reducer. Authors writing tools that consume the runtime spec (rather than the registered metadata) should consume machine-meta for the user-facing shape; the runtime form is intentionally not part of the public contract and may evolve. Per audit Finding 3.

Composition with explicit :entry / :exit

A state may declare both :spawn AND user-supplied :entry / :exit. The user-supplied actions run first in each slot:

  • On enter: the user's :entry action runs, then the auto-spawn fx is emitted.
  • On exit: the user's :exit action runs, then the auto-destroy fx is emitted.

Rationale: the user's :entry is for setup work that must happen before the child starts (e.g., normalising data, recording a start timestamp). The spawn happens after that setup completes, so the child sees the post-setup snapshot. On exit, the user's :exit action gets to read the actor's final snapshot before the auto-destroy clears it — useful for capturing the child's last reported value. Address the child via the runtime registry — (get-in db [:rf/runtime :machines :spawned <parent-machine-id> <invoke-id>]) resolves to the gensym'd id and (get-in db [:rf/runtime :machines :snapshots <id>]) reads the snapshot from there — or by :system-id name (per §Recording the spawned id user-side).

The composition is wire-level concatenation, not nesting — the action ordering is [user-entry, auto-spawn] for entry and [user-exit, auto-destroy] for exit. Each runs as a normal action, returning its own {:data :fx} effect map; the runtime drains them in order per §Drain semantics — Level 2.

:entry and :exit remain singular slots (§State nodes) — the user writes one fn or one registered id, and the desugaring of :spawn adds exactly one more action to each slot. There is no user-visible vector form.

Composition with hierarchical states

A :spawn-bearing state can sit at any level of a compound hierarchy. The :spawn slot produces ordinary :entry / :exit actions — the existing entry/exit cascading machinery from §Entry/exit cascading along the LCA handles them naturally.

Concretely:

  • A child state's :spawn fires its spawn when the child is entered (which may happen as part of a deeper cascade — e.g., entering a compound parent for the first time also enters the parent's :initial cascade target).
  • A parent state's :spawn fires its destroy when the parent is exited — and the parent is exited only when a transition's LCA is above it. Sibling-leaf transitions inside the parent do NOT destroy a parent-level :spawn actor.
  • Multiple :spawn-bearing ancestors along a deep path each contribute their own spawn/destroy pair, ordered by the cascade direction (entry: outermost first; exit: innermost first).

The desugaring is uniform — no special-casing for hierarchy. Whatever cascading rules the existing entry/exit machinery applies are exactly what :spawn inherits.

Errors

:spawn introduces no new error categories. Failures route through the existing :rf.error/* machinery:

  • If :data is a function and it throws, the error surfaces as :rf.error/machine-action-exception (the standard category for any user-supplied fn that throws during a machine action — see Cross-Spec-Interactions §11). The transition halts; the snapshot does not commit.
  • If :machine-id references an unregistered machine, the spawn fx itself errors per existing spawn semantics — no :spawn-specific category.
  • If the user supplies neither :machine-id nor :definition, make-machine-handler rejects the spec at registration time as a malformed transition table — the schema makes "exactly one of :machine-id or :definition" a registration-time constraint per Spec-Schemas §:rf/state-node.

Deliberate omissions vs xstate

xstate's invoke admits several features re-frame2 deliberately omits. Each has a substitute that fits re-frame2's existing primitives:

xstate feature re-frame2 substitute
onDone — fire a callback when the child reaches a final state re-frame2 ships first-class final states with parent notification — see §Final states (:final? / :on-done / :output-key). A leaf state declares :final? true (and optionally :output-key); the parent's :spawn declares :on-done (fn [{data :data result :result}] new-data). The runtime invokes :on-done synchronously when the child enters its final state, then auto-destroys the child.
onError — child error callback Errors flow through the standard :rf.error/* machinery and are visible in trace events. The parent observes via the existing error envelope, not a :spawn-specific hook.
Multiple :spawn per state (xstate admits a vector) One :spawn per state. Multiple actors per state suggests refactoring into a compound state where each substate invokes one of the actors.
autoForward — forward all parent events to the child Users forward explicitly via :fx [[:dispatch [child-id ev]]] from the relevant transitions. Implicit forwarding is invisible at the call site; explicit forwarding is what visualisers and AIs read.

Each omission is consistent with the spec's broader bias: prefer one explicit primitive over many implicit conveniences. The substitutes use mechanisms already required for spawn / destroy / dispatch / :raise; :spawn is the only new sugar in this area, and even it is desugared at construction time.

Worked example — declarative login flow

{:initial :idle
 :states
 {:idle  {:on {:submit :authenticating}}

  :authenticating
  {:spawn {:machine-id :http/post
            :data       (fn [{snap :snapshot}]
                          {:url  "/api/login"
                           :body (-> snap :data :credentials)})
            :system-id  :auth-actor}    ;; address the child by name
   :on     {:auth/succeeded :authenticated
            :auth/failed    :idle}}

  :authenticated {...}}}

The walk-through:

  1. User submits → state moves :idle:authenticating.
  2. Entering :authenticating triggers the desugared entry: spawn an :http/post actor with the credentials from :data; the runtime binds the spawned id at [:rf/runtime :machines :spawned :login [:authenticating]] in the frame's app-db and registers the :system-id :auth-actor name, so other transitions in the parent can address the child via (rf/machine-by-system-id :auth-actor).
  3. The HTTP child runs; on success, it dispatches [:login [:auth/succeeded ...]] (where :login is the parent machine's id).
  4. The login machine handles :auth/succeeded; transitions to :authenticated.
  5. Leaving :authenticating triggers the desugared exit: the runtime reads the actor id back from [:rf/runtime :machines :spawned :login [:authenticating]], destroys it, clears the slot, and releases the :auth-actor :system-id binding. The HTTP child's snapshot is removed from [:rf/runtime :machines :snapshots] automatically — no stale id lingers in the parent's :data.
  6. If the user abandons mid-flight (a different transition fires :authenticating:idle), the exit cascade still runs; the in-flight HTTP child is destroyed; no actor leaks.

The key property: the parent does not have to remember to destroy the child. The lifecycle binding is declared once at the state level, and the exit cascade enforces it on every code path out of the state — including ones the author hasn't yet thought of.

Per, the framework's :rf.http/managed ships as both an fx AND a child-invokable machine for exactly this pattern — apps no longer hand-roll the HTTP-child wrapper:

{:authenticating
 {:spawn {:machine-id :rf.http/managed
           :data       {:request {:method :post :url "/api/login"
                                  :body credentials}}}
  :on     {:succeeded :authenticated
           :failed    :idle}}}

See Spec 014 §Machine-shape wrapper for the wrapper's contract; the wrapper's terminal :succeeded / :failed events arrive at the parent exactly as the hand-rolled HTTP child machine would emit them.

Cross-references: §Spawning for the imperative-spawn surface that :spawn desugars to; §Composition with explicit :entry / :exit for the auto+manual ordering rule; Spec-Schemas §:rf/state-node for the :spawn schema. Pattern-WebSocket is the canonical worked example exercising hierarchical states, :after, :always, machine-scoped :guards / :actions, and :spawn together — the connection-lifecycle state machine for long-lived sockets. Pattern-LongRunningWork is the canonical worked example for chunked CPU-intensive work — :always for batch progression, :after 0 for browser yielding between chunks, machine-scoped guards for completion / cancellation.

Final states (:final? / :on-done / :output-key)

re-frame2 ships first-class final states with parent notification — the xstate-style "child reaches done; parent sees onDone" pattern. The earlier post-v1 note ("user code can dispatch on entry to a terminal state in v1") is superseded.

The grammar

A leaf state may declare :final? true. Entering that state terminates the machine:

  • If the machine was spawned by a parent's :spawn, the parent's :spawn :on-done (fn [{data :data result :result}] new-data) fires (with result = the child's :data slot named by the final state's :output-key, or nil when :output-key is absent). The child is then auto-destroyed.
  • If the machine is a singleton (registered top-level, no parent :spawn), the machine still auto-destroys on entry to :final? — "final means final" (D7 below). Apps wanting a persistent terminal state simply omit :final? and use an ordinary leaf state.
;; Child machine — declares its terminal state with :final? + :output-key.
(rf/reg-machine :auth-flow
  {:initial :running
   :data    {}
   :states
   {:running {:on {:server-ok {:target :done
                               :action (fn [{data :data ev :event}]
                                         {:data (assoc data :token (second ev))})}}}
    :done    {:final?     true
              :output-key :token}}})

;; Parent machine — :on-done reads the child's reported result.
(rf/reg-machine :login
  {:initial :idle
   :states
   {:idle
    {:on {:submit :authenticating}}

    :authenticating
    {:spawn {:machine-id :auth-flow
              :on-done    (fn [{data :data result :result}] (assoc data :token result))}
     :on    {:auth/cancelled :idle}}}})

When :auth-flow enters :done, the runtime:

  1. Reads the child's :data at :output-key :token — call it result.
  2. Looks up the parent's :spawn at the :rf/spawn-id recorded on the child's :data (stamped at spawn time).
  3. Runs the parent's :on-done against the parent's :data with result — the returned map replaces the parent's :data slot.
  4. Emits :rf.machine/done (per §Trace events) with :machine-id (the child), :output result, :parent-id.
  5. Tears down the child via the existing destroy path with :reason :rf.machine/finished enriched onto the :rf.machine/destroyed trace.
  6. Clears the child's [:rf/runtime :machines :system-ids <sid>] reverse-index entry (if it had one) after step 3 — so :on-done can still read the binding.

Sub-decisions (locked)

# Decision
D1 :final? is a first-class key on the state node, not stashed under :meta. Visibility wins — :final? is a strong runtime signal and authors / AI agents see it at the state level.
D2 The parent-notification hook is :on-done on the parent's :spawn map (mirrors :on-spawn). Signature (fn [{:keys [data result]}] new-data) — unified context-map; returns the parent's new :data map.
D3 Output is sourced via :output-key on the child's final state — a designated key into the child's :data. There is no :output-fn escape hatch; one explicit primitive, not two. Apps wanting computed output write a :action on the transition INTO the final state that stashes the computed value at :output-key.
D4 Auto-destroy is synchronous and happens on the same tick the machine entered :final?. The standard destroy path runs (in-flight HTTP aborts, registrar unregister, [:rf/runtime :machines :spawned ...] slot clear, [:rf/runtime :machines :snapshots <id>] snapshot dissoc).
D5 A dispatch arriving at the now-destroyed actor address is handled by the existing destroyed-frame trace path:rf.error/no-such-handler (or the per-runtime equivalent). No new :rf.machine/dispatched-while-done half-state is introduced.
D6 New trace event :rf.machine/done carries :machine-id, :output, :parent-id (the parent's registration id, or nil for singletons). The existing :rf.machine/destroyed trace is enriched with a :reason tag — one of :rf.machine/finished, :explicit, or :parent-unmount-cascade.
D7 Singleton symmetry — a singleton (non-spawned, non-invoked) machine reaching :final? ALSO auto-destroys. Footgun note for skill docs: if you want a persistent terminal state, omit :final?.
D8 :system-id interaction — the runtime auto-clears [:rf/runtime :machines :system-ids <system-id>] reverse-index entry on done. The clear runs after :on-done fires so the hook can still read the binding.
D9 Specified and implemented in one delivery (no post-v1 deferral).
D10 Capability-matrix axis: :fsm/final-states (naming consistent with :fsm/parallel-regions, :fsm/tags). Conformance fixtures final-state-singleton-auto-destroys and final-state-child-fires-on-done exercise the contract.

:final? constraints

  • Leaf-only. A state declaring :final? true MUST NOT declare :states (or :initial). Compound states cannot themselves be final — their finality is expressed by a leaf inside them. make-machine-handler rejects compound :final? declarations at registration with :rf.error/machine-final-state-compound.
  • No :on, :always, :after, :spawn, :spawn-all on a :final? state. Final means final — no further transitions. make-machine-handler rejects these combinations at registration with :rf.error/machine-final-state-has-transitions. :entry and :exit actions ARE permitted (the final-state's :entry runs as part of the entering cascade; :exit runs from the auto-destroy teardown).
  • :output-key requires :final?. A non-final state declaring :output-key is a registration error (:rf.error/machine-output-key-without-final). On a final state, :output-key is optional — when absent, the result passed to :on-done is nil.
  • Parallel regions and :final?. A leaf inside one region of a parallel-region machine may declare :final? true; the meaning is "this region has reached its final state." That region halts (no further transitions accepted for it; sibling regions continue). The parent machine as a whole is :final? only when EVERY region's active state is :final? — at which point the auto-destroy and :on-done cascade fires as usual. (This composability uses the existing parallel-region routing; no new primitive.)

Composition with :entry / :exit

A final state's :entry action runs as part of the entering cascade (before the auto-destroy fires). A final state's :exit action runs from the auto-destroy teardown — same ordering convention as the user-supplied :exit running before the auto-destroy for ordinary :spawn-bearing states. The user's :exit therefore gets to read the final snapshot (including :data's :output-key-designated slot) before auto-destroy clears it.

Trace events fired on done

Synchronous ordering (per D4):

  1. :rf.machine/done — emitted with :machine-id (the finishing actor), :output (the value read at :output-key, or nil), :parent-id (the parent's registration id, or nil for singletons).
  2. :rf.machine/destroyed — enriched with :reason :rf.machine/finished (the discriminator that distinguishes "the actor finished naturally" from "the parent cascade destroyed it").
  3. :rf.machine/system-id-released — when the actor was :system-id-bound. Fires AFTER :on-done ran (so :on-done could still look up the binding).

Existing observers that filter :rf.machine/destroyed on :tags see the new :reason tag additively — no breaking change.

Cross-references

  • §Spec-spec keys:on-done is listed alongside :on-spawn on the parent's :spawn map.
  • §Deliberate omissions vs xstate — the onDone row now records that re-frame2 DOES ship final-state-with-on-done.
  • Spec-Schemas §:rf/state-node — schema for :final? and :output-key.
  • Spec 009 §:op-type vocabulary:rf.machine/done registration.
  • Conformance fixtures: final-state-singleton-auto-destroys.edn, final-state-child-fires-on-done.edn.
  • §Destroy is silent-idempotent — D4's auto-destroy is followed at most ONCE by an observable :rf.machine/destroyed trace; subsequent explicit [:rf.machine/destroy <id>] calls on the same finished actor are silent no-ops (aligned with XState convention). -.

History states (:type :history — shallow / deep / default-target)

re-frame2 ships first-class history states — the xstate / SCXML pattern for re-entering a compound state at the substate it was in when control last left it, rather than restarting from :initial. An earlier draft of this spec deferred history past v1 and pointed authors at a hand-rolled snapshot-as-value substitute; that deferral is withdrawn. History is now a declarative grammar node the runtime records and restores, and the substitute pattern is removed entirely (see §Why first-class history replaces the snapshot-as-value substitute below).

The grammar — a targetable pseudo-state

A history state is a pseudo-state: a node declared under a compound state's :states map, alongside that compound's real substates, whose role is to be a transition target that resolves to a recorded configuration rather than to a state the machine ever occupies. The machine's :state path is never [… :hist]; a transition to :hist resolves to the recorded (or default) leaf, and that resolved leaf is what the snapshot records.

{:player
 {:initial :stopped
  :states {:stopped {:on {:play [:player :hist]}}      ;; transition targets the pseudo-state to restore
           :hist    {:type :history
                     :deep? true                        ;; omit => SHALLOW history
                     :default-target :playing}          ;; omit => falls back to :player's :initial
           :playing {:initial :at-start
                     :states {:at-start {:on {:seek :mid-track}}
                              :mid-track {}}}
           :paused  {:on {:resume [:player :playing]}}}}}

The pseudo-state node carries exactly three keys, all owned by the history grammar:

Key Value Meaning
:type :history Marks the node as a history pseudo-state. Required — this is the discriminator that distinguishes the node from a real substate.
:deep? boolean true => deep history (restore the full recorded leaf path beneath the compound); false or absent => shallow history (restore the recorded direct child of the compound, then cascade through that child's own :initial chain). The default is shallow — a missing :deep? reads as :deep? false.
:default-target <transition-target> The target used when the compound state has never been entered (so nothing is recorded yet). A keyword (sibling of the compound — i.e. a direct child) or a vector (absolute path). When absent, the fallback is the owning compound state's :initial.

A transition resolves to the pseudo-state by naming it the way any other state is named — a vector [:player :hist] (absolute path) or, from a sibling inside the compound, a keyword :hist (per §Target resolution — vector vs keyword). The pseudo-state is resolved at transition time to a real leaf path; the resolved path is what the entry cascade enters and what the snapshot's :state records.

Recording — on compound-state exit

Every time the exit cascade (per §Entry/exit cascading along the LCA) leaves a compound state that owns a history pseudo-state, the runtime records that compound's last-active configuration — the active substate path beneath the compound at the moment of exit — into the reserved snapshot slot :rf/history (per §The :rf/history snapshot slot below). The recording is keyed by the compound's declaration path (the absolute prefix-path of the compound state-node in the transition table), so a machine with several history-bearing compounds records each independently.

  • A deep-history compound records the full leaf path beneath itself (e.g. [:playing :mid-track] relative to :player's subtree, stored as the absolute path).
  • A shallow-history compound records only its direct child (e.g. :playing); on restore the runtime cascades from that child through its own :initial chain.

Recording happens as part of the exit cascade's commit, on the same drain that exits the compound — there is no separate write phase. Because :rf/history lives inside the snapshot (a revertible value), the recording rides every persistence and time-axis path that the snapshot itself rides (per §Composition with persistence, SSR, and time-travel).

A compound state that owns no history pseudo-state records nothing — the slot stays absent for that compound's path. The slot is fixed-and-additive and framework-owned; user code MUST NOT write under it.

Restoring — on transition to the pseudo-state

When a transition resolves to a history pseudo-state declared under compound C, the runtime resolves the target leaf as follows:

  1. A recording exists for C's declaration path (C has been entered and exited at least once, and the recorded path is still valid against the current definition):
  2. Deep — the target leaf is the full recorded path. The entry cascade enters every level from C down to that leaf, firing each level's :entry shallowest-first (per §Entry/exit cascading along the LCA).
  3. Shallow — the target is the recorded direct child of C; the runtime then cascades through that child's :initial chain to a leaf (per §Initial-state cascading). If the recorded direct child is itself a leaf, the cascade terminates there.
  4. No recording exists for C (the compound was never entered, so nothing was recorded) — the runtime resolves the pseudo-state's :default-target; if :default-target is absent, it falls back to C's :initial and cascades from there exactly as an ordinary entry to C would.
  5. A recording exists but is no longer a valid path in the current (e.g. hot-reloaded) definition — the runtime treats it as "no recording" and falls back to :default-target / :initial per (2). See §Dangling recorded paths after hot reload.

In every case the resolved leaf path — not the pseudo-state — is what the entry cascade enters and what the post-transition snapshot's :state records. The pseudo-state node is never a member of any :state configuration; it has no :entry / :exit / :on / :always / :after of its own (see §Pseudo-state constraints).

Composition with the LCA, entry/exit cascade, and final states

History restoration is not a new cascade mechanism — it is a target-resolution step that feeds the existing entry-cascade machinery. Once the pseudo-state resolves to a concrete leaf path, the standard geometry applies unchanged:

  • LCA + cascade ordering. The transition's LCA is computed (per §Entry/exit cascading along the LCA) between the source path and the resolved target leaf — exactly as if the author had written the resolved path as a literal :target. The exit cascade fires deepest-first from the source leaf back to the LCA; the transition :action runs at the LCA boundary; the entry cascade fires shallowest-first from below the LCA down to the resolved leaf, continuing through any :initial chain (shallow case). The recording write for the source compound (if it owns history and is being exited) happens as part of that same exit cascade.
  • Deep nesting. A deep-history compound nested several levels down records and restores its full subtree path relative to itself; an outer compound's own history (if any) records independently, keyed by its own declaration path. The two never interfere — each compound's recording is a separate entry in the :rf/history map.
  • Final states. Entering a :final? leaf inside a history-bearing compound records normally on the way in only if a later exit occurs; in practice a singleton reaching :final? auto-destroys (per §Final states) and the snapshot is dissoc'd, so its :rf/history goes with it. Restoring a recorded path whose leaf was :final? is a no-op edge the runtime handles via the dangling-path fallback (a :final? leaf the definition still declares restores like any other leaf; one the definition removed falls back to :default-target / :initial).
  • :always / :after. The resolved leaf's :always entries are checked after the restoring entry cascade settles (microstep loop, per §Eventless :always transitions); its :after timers are scheduled at entry (per §Delayed :after transitions) — identical to entering that leaf by any other path.

Composition with parallel regions — per-region history

Under a :type :parallel machine each region runs an independent state-tree (per §Parallel regions), so history is per-region: a history pseudo-state is declared inside a region's compound state, records that region's last-active configuration on the region's own exit cascade, and restores that region independently of its siblings. The :rf/history slot's keys are therefore region-qualified — a recorded compound's key is its declaration path including the region name as the head segment ([<region-name> … <compound>]), so two regions that each declare a history-bearing compound at structurally-identical paths never collide. A transition that restores history in one region leaves sibling regions untouched, matching the per-region scoping of :spawn / :after / :always (per §Per-region :always / :after / :spawn scoping).

The :rf/history snapshot slot

The recorded history lives in a reserved snapshot-root slot, a sibling of :rf/spawn-counter and :rf/machine-type (per §Reserved snapshot-internal keys):

{:state :stopped
 :data  {}
 :rf/history {[:player]        [:playing :mid-track]    ;; deep — full leaf path beneath :player
              [:player :inner] :paused}}                ;; shallow — recorded direct child

:rf/history is a map keyed by compound declaration path (a vector of keywords) to that compound's recorded configuration. It is NOT a single config — a machine may own several history-bearing compounds, each recorded independently; and under :type :parallel the keys are region-qualified (head segment is the region name), so per-region recordings never collide. The recorded value is:

  • for a deep compound, the absolute leaf path the machine occupied beneath that compound when it last exited;
  • for a shallow compound, the recorded direct child keyword (the runtime cascades its :initial chain on restore).

The slot is read-only for users (the runtime owns it and writes it during the exit cascade), EDN-clean (vectors and keywords only — round-trips through pr-str / read-string like the other persisting slots), and absent until a history-bearing compound is first exited (allocated lazily; a machine with no history pseudo-states never carries the key). See Spec-Schemas §:rf/machine-snapshot for the slot schema and Conventions §Reserved snapshot-internal keys for the catalogue row.

Composition with persistence, SSR, and time-travel

Because :rf/history lives inside the snapshot — and the snapshot is a revertible value at [:rf/runtime :machines :snapshots <id>] — recorded history rides every path the snapshot itself rides, with no separate machinery:

  • pr-str / read-string round-trip — the slot is vectors-and-keywords only, so it survives the wire (invariant 1 of §Snapshot shape).
  • SSR hydration (011) — a snapshot serialised on the server arrives on the client with its :rf/history intact; a subsequent restore-to-history transition resolves against the server-recorded configuration.
  • Tool-Pair epoch replay (Tool-Pair.md §Time-travel) — restoring an earlier epoch restores the :rf/history of that epoch along with the rest of the snapshot, so "rewind, then re-enter the compound" replays deterministically. The epoch-restore precondition keys off :rf/machine-type (per §Liveness is derived from app-db), which :rf/history does not affect — a history-bearing snapshot is admitted as a valid restore target exactly like any other.

This is the property the withdrawn substitute was hand-rolling; first-class history gets it for free because the recording is part of the snapshot value rather than a side-table.

Coverage boundary. The three round-trips above are all properties of the snapshot valuepr-str / read-string symmetry, and that re-running the engine from an earlier captured snapshot value resolves against THAT value's :rf/history (which is exactly what restore-epoch and SSR hydration each do — rewind / transport the value, then re-enter). The machines reference therefore proves them at the value level (unit tests history-slot-is-part-of-the-revertible-snapshot-value and history-slot-edn-round-trips), not by driving a live restore-epoch or render-to-string round-trip: those surfaces live in the separate re-frame2-epoch / re-frame2-ssr artefacts, and depending on them from the machines test layer would introduce a cross-artefact (cyclic) test dependency for no additional signal — the value-level proof is the load-bearing one, because :rf/history is opaque to both the epoch-restore precondition (which keys off :rf/machine-type) and the SSR serialiser (which round-trips the snapshot as data).

Dangling recorded paths after hot reload

Snapshot stability invariant 3 (per §Snapshot shape) says hot-reloading a definition does not invalidate a snapshot whose :state is still a member of the new definition. History adds a parallel edge case: a recorded configuration in :rf/history may reference a substate that a hot-reloaded definition removed — a dangling recorded path. The restore policy (the history analogue of invariant 3):

On a restore-to-history transition, if the recorded configuration for the compound is no longer a valid path in the current definition, the runtime discards it and falls back to the pseudo-state's :default-target — or, when :default-target is absent, to the compound's :initial — cascading from there exactly as a first-ever entry would. The runtime never enters a dead recorded path.

The recorded slot itself is left in place (it is overwritten on the next genuine exit); only the restore is graceful. This policy is pinned here in the keystone; its engine realisation and round-trip / SSR-hydration test coverage are tracked under rf2-mle6e.3 (engine) / mle6e.7 (verify), and the dangling-path edge specifically under [rf2-wgfv0] (left open for its engine half). A dangling recorded path is a benign, expected consequence of hot reload — not an error; no :rf.error/* is raised for it (it is observable through the history trace events per Spec 009, tracked under mle6e.2).

Pseudo-state constraints

make-machine-handler validates the history grammar at registration time (the same layer that rejects malformed compound states):

  • A :type :history node MUST be declared inside a compound state's :states (it has an owning compound whose configuration it records). A history node at the machine root, or under a :type :parallel root that has no enclosing compound region, is a registration error.
  • A history pseudo-state declares only :type / :deep? / :default-target. It MUST NOT declare :states, :initial, :on, :always, :after, :spawn, :spawn-all, :entry, :exit, :tags, or :final? — it is never occupied, so transition / lifecycle / projection keys are meaningless on it. Any such key is a registration error.
  • :default-target, when present, MUST resolve to a real state — a direct child of the owning compound (keyword form) or an absolute path (vector form) the definition declares. An unresolvable :default-target is a registration error.
  • A compound state may declare at most one history pseudo-state. Two history children under one compound is a registration error (deep-vs-shallow is a property of the single node's :deep?, not a reason for two nodes).

These are the registration-time guarantees; the precise error-id catalogue for history-grammar violations is owned by Spec 009 §Error contract and added under mle6e.2 (with mle6e.3 raising them in the reference engine).

Why first-class history replaces the snapshot-as-value substitute

Earlier drafts argued that re-frame2 did not need first-class history — that the snapshot-as-value foundation (Goal 3 — Frame state revertibility) made history-state machinery unnecessary, because an author could capture a compound's snapshot on the way out and restore it on the way back in. That thesis is withdrawn (ruled 2026-06-03). First-class history is ratified, and the snapshot-as-value substitute is removed — not demoted to a "lightweight pattern", but excised. The reasons the substitute argument no longer holds:

  • Declarative beats hand-rolled. {:type :history :deep? true} is one node in the transition table; the substitute was per-compound user wiring (an :exit action that copies a sub-path, an entry that restores it, plus the bookkeeping to know which compound and — under parallel — which region). The declarative form is what an AI agent reads and writes confidently; the hand-rolled form is bespoke per machine.
  • Composition the substitute could not give cheaply. Per-region history under :type :parallel, deep nesting, and shallow-vs-deep depth all fall out of the grammar + the existing LCA / cascade machinery. The substitute had to re-implement each of these by hand, correctly, per machine.
  • Tooling legibility. A :type :history node is visible to the machine inspector, the diagram exporter, and the SCXML / xstate corpus (which both have first-class history); a hand-rolled capture/restore is invisible to all of them — it reads as ordinary :data shuffling.
  • Parity. Both SCXML (<history>) and xstate ({type:'history'}) ship first-class history; matching the shape keeps the conformance corpus and the AI-trained-on-xstate path aligned (per §Lessons from xstate).

Revertibility (Goal 3) remains the substrate that makes history cheap — the recording rides the snapshot for free — but it is the foundation history is built on, not a reason to skip the feature. Authors who only need "remember a single flat axis across a leave/return" can still keep that axis in :data like any other state; that is ordinary modelling, not a named substitute pattern.

Capability gating

History states are claimed as :fsm/history in the v1 CLJS reference per §Capability matrix. A port that does not claim :fsm/history rejects a :type :history node at registration time with :rf.error/machine-grammar-not-in-v1 (the same reject-at-registration disposition every other unclaimed capability uses — per §Error category for unclaimed grammar). The schema extension (the :rf/state-node history-pseudo-state arm; the :rf/machine-snapshot :rf/history slot) is documented in Spec-Schemas §:rf/transition-table and §:rf/machine-snapshot.

Cross-references

Wall-clock timeouts on :spawn — use parent state's :after

:spawn and :spawn-all do not carry their own :timeout-ms slot. Wall-clock timeouts on a state hosting a :spawn are expressed by adding an :after entry on the parent state: when the timer fires, the standard exit cascade tears down the in-flight child via :rf.machine/destroy and the parent transitions to whichever target the :after entry names. :after is the single canonical primitive for "after N ms in this state, do X"; no second mechanism is needed for the :spawn-bearing case.

Why one primitive, not two

An earlier draft of this spec carried a :timeout-ms slot on :spawn / :spawn-all for "the whole spawned actor must terminate within N ms (spanning retries)." That slot is dropped. The motivating use case — a boot machine wanting "the auth phase completes in 30 s total, including retries" — is fully served by the parent state's :after map (per §Whichever fires first wins and the cancellation cascade). Maintaining two timeout mechanisms (state-level :after + invoke-level :timeout-ms) created a learnability tax with no expressive benefit. Per the boot-as-state-machine §M3 follow-up, the M3 finding's resolution is now "use the parent state's :after."

{:authenticating
 {:spawn {:machine-id :auth-flow}
  :after  {30000 :auth-failed}                 ;; wall-clock guard — spans retries inside the child
  :on     {:auth/succeeded :authenticated}}}

When the 30000 ms :after timer fires, the parent's exit cascade destroys the :auth-flow child (which itself cascades any in-flight :rf.http/managed aborts per the §Cancellation cascade — in-flight :rf.http/managed aborts contract), and the machine transitions to :auth-failed. The wall-clock spans the child's retries because the timer is anchored to state entry of :authenticating, not to any individual HTTP attempt; the child's internal retry behaviour does not affect the parent's :after countdown.

Symmetric for :spawn-all:

{:hydrating
 {:spawn-all
  {:children         [{:id :cfg  :machine-id :load-config}
                      {:id :flag :machine-id :load-feature-flags}
                      {:id :user :machine-id :load-user-profile}
                      {:id :dash :machine-id :load-dashboards}]
   :join             :all
   :on-child-done    :asset/loaded
   :on-child-error   :asset/failed
   :on-all-complete  [:hydrate/done]
   :on-any-failed    [:hydrate/failed]}
  :after {60000 :hydrate/timed-out}             ;; whole-join wall-clock guard
  :on   {:hydrate/done       :ready
         :hydrate/failed     :error
         :hydrate/timed-out  :degraded}}}

The 60000 ms :after fires if the join hasn't resolved by the deadline; the standard exit cascade cancels every surviving child (the :spawn-all desugared :exit action handles per-child cleanup, same as cancel-on-decision per §Cancel-on-decision), and the parent transitions to :degraded. No :timeout-ms slot, no :on-timeout slot, no :rf.machine.spawn/timed-out trace — the standard :after machinery covers everything the dropped :timeout-ms slot used to.

Partial-progress is not preserved

A :after-driven cascade out of the :spawn-bearing state destroys any spawned child and clears the runtime spawn-registry slots; the parent's transition handler may not assume any of the child's partial state has flushed back into the parent's :data. For :spawn-all, the join state at [:rf/runtime :machines :spawned <parent> <invoke-id>] is destroyed alongside the children — the parent cannot read which children had completed at the moment of timeout. Apps that need "take whatever loaded by the deadline" semantics declare a separate :always on the parent state that fires :on-some-complete when a partial-success guard becomes true, per the :after + partial-success idiom documented under §Spawn-and-join via :spawn-all §Composition with hierarchy and :after.

Cross-references

  • §Whichever fires first wins — the cancellation cascade that an :after firing triggers is the same cascade as a parent-destroys-child shutdown.
  • §Delayed :after transitions — the canonical primitive's full grammar and semantics.
  • Boot-as-state-machine §M3 — the boot-machine use case that originally motivated :timeout-ms; the M3 finding's resolution is now "use the parent state's :after."
  • MIGRATION §M-44 — pre-1.0 spec lock; the dropped-slot record.

Cancellation cascade — in-flight :rf.http/managed aborts

Resolves boot-as-state-machine §M2. The pre-resolution gap was: when a parent state machine cancels a spawned child mid-execution (parent state exit, parent destroy, :after firing, :spawn-all cancel-on-decision), what happens to in-flight :rf.http/managed requests the child kicked off? Spec 005 + Spec 014 didn't explicitly cover the cross-feature contract. This section is the contract.

The contract

When the runtime destroys a spawned actor — by any trigger — every in-flight :rf.http/managed request that was issued from inside that actor's event handlers is aborted. Triggers include:

  1. Parent state exit. The standard exit cascade emits :rf.machine/destroy for the :spawnd child (per §Declarative :spawn §Desugaring rules). The destroy handler aborts the child's in-flight HTTP.
  2. Parent's :after firing. :after exit is a state exit; the cascade above runs unchanged (per §Whichever fires first wins).
  3. :spawn-all cancel-on-decision. When the join resolves and :cancel-on-decision? is true (the default), the runtime emits :rf.machine/destroy per surviving sibling (per §Cancel-on-decision). Each siblings' in-flight HTTP aborts.
  4. :spawn-all parent state exit. Symmetric to (1), but the per-child teardown loop (per §Spawn-id tracking) cascades the abort to every child the :children map tracks.
  5. Imperative [:rf.machine/destroy <actor-id>]. A user-authored destroy action emitting the legacy keyword form (per the spawn-fx 5-arity destroy) ALSO aborts that actor's in-flight HTTP. The contract is uniform across triggers — wherever an actor is destroyed, its HTTP cascades to abort.
  6. Frame destroy. frame.cljc's frame-exit walk over surviving machine instances destroys each in turn (per Spec 002 §Lifecycle); each destroy fires the same abort-on-actor-destroy hook.

The abort surfaces as a normal :rf.http/aborted failure on the request's reply path — the :on-failure callback (or the merged-reply default) sees {:kind :rf.http/aborted :reason :actor-destroyed} per Spec 014 §Aborts. For most calling code there is no observable difference from a manual :rf.http/managed-abort; the :reason :actor-destroyed discriminates for callers that care.

What is "in-flight inside an actor"

A request is "in-flight inside actor <spawned-id>" if and only if its originating event vector's first element was <spawned-id>. The originating event vector flows to the :rf.http/managed fx through the standard fx 5-arity (:event on the fx ctx, per Spec 002 §Routing the dispatch envelope), and the http fx records the (request-id, actor-id) tuple in its in-flight registry alongside the abort-handle.

The actor-id is the spawned actor's own machine address (e.g., :http/post#1), not the parent's address. A request that the parent (:auth/main) issued directly is NOT in-flight inside any spawned actor — it is in-flight inside the parent's event-handler context, which has no spawn-registry slot. The parent's request is unaffected by any child-actor destroy. See §Open question — direct dispatches from event handlers.

What about the request's own :request-id?

The :request-id (per Spec 014 §Aborts) is orthogonal to the actor-id. A request can carry both (a stable :request-id for app-level abort/supersede AND an actor-id stamped by the runtime); the in-flight registry indexes both ways. A :rf.http/managed-abort fx with the request-id aborts the one request; the actor-destroy hook walks every request whose actor-id matches and aborts each. Neither indexing supersedes the other; they coexist.

If a request was issued without :request-id from inside a spawned actor, it is still tracked by actor-id and is still aborted on actor-destroy. The :request-id is for app-level addressability; actor-id tracking is for runtime cleanup.

Open question — direct dispatches from event handlers

Events dispatched directly from ordinary reg-event-fx handlers — i.e. the originating event vector is for an event-id that is NOT a spawned actor's address — issue :rf.http/managed requests that are NOT subject to the actor-destroy cancellation cascade. There is no actor-id to correlate against.

This is deliberate. Cancellation tied to actor lifetime is semantically the right scope: the child actor exists to run until the parent says "we no longer care"; the parent saying so kills the actor and the actor's outstanding work. An ordinary event handler has no analogous lifecycle peg — its work is launched as a side effect and outlives the handler that fired it; the only way to abort it is via an explicit :rf.http/managed-abort keyed on the user-supplied :request-id, exactly as before this contract.

If an app wants HTTP requests that are tied to a state's lifetime, the answer is to spawn a child machine that issues them — the :spawn or :spawn-all declaration is the explicit way to bind HTTP-request lifetime to state-occupancy lifetime. There is no ambient "abort on every state transition out" sugar for direct-dispatch HTTP.

The hook

The destroy-side abort fires through a late-bind hook (per re-frame.late-bind) — re-frame.machines does NOT statically :require re-frame.http-managed. The hook key is :http/abort-on-actor-destroy; the http artefact registers a fn (fn [actor-id]) at ns-load time; the machines artefact's destroy path looks the fn up at call time and invokes it once per destroyed actor. When the http artefact is not on the classpath the hook resolves to nil and the destroy proceeds without any abort cascade — apps that don't issue managed-HTTP requests pay nothing.

Symmetric to how re-frame.machines already publishes :machines/spawn-fx / :machines/destroy-machine-fx (per re-frame.late-bind hook table) and how re-frame.flows and re-frame.routing flow up their own seams.

Trace event

Each individual abort emits :rf.http/aborted-on-actor-destroy (registered in Spec 009 §Error event catalogue). One trace per cancelled request. The trace's :tags carry :request-id (when set on the request), :actor-id (the destroyed spawned-actor address), and :url (the request's URL).

The reply payload itself is a standard :rf.http/aborted failure; tools that subscribe to the http-failure-category trace stream see this category alongside the user-initiated aborts. The :reason :actor-destroyed tag is the discriminator.

Why one mechanism, not two

The same hook fires across every destroy trigger — :spawn exit, :spawn-all exit, cancel-on-decision, :after cascade, frame destroy. There is no per-trigger HTTP-abort code path. This means:

  • Authors writing a :spawn-based child whose body fires :rf.http/managed get cleanup automatically — no :exit action threading :rf.http/managed-abort calls per known :request-id.
  • The "parent reloads mid-flight" case (boot-as-state-machine §M2) is covered by the frame-destroy walk firing the same hook against every surviving machine.
  • The exit cascade from :after (per §Whichever fires first wins) reuses the destroy path, so the wall-clock-timeout case is identical to the parent-decides-to-cancel case.

Cross-references

Spawn-and-join via :spawn-all

:spawn-all is declarative sugar for "spawn N children in parallel, fire one of three parent events when the join condition resolves." It is the answer to the boot-as-state-machine pattern: hydrate phases that fan out N requests and join on a :seen-all-of? predicate (per boot-as-state-machine §M1).

:spawn-all is registration-time sugar. make-machine-handler walks the spec at construction time and rewrites every :spawn-all slot into entry/exit actions emitting N parallel :rf.machine/spawn fx (on entry) and per-child :rf.machine/destroy fx (on exit), plus an internal join-state hook that watches the parent's events for child-completion signals and fires the parent-level join event when the join condition resolves.

The pattern

{:hydrating
 {:spawn-all
  {:children         [{:id :cfg  :machine-id :load-config}
                      {:id :flag :machine-id :load-feature-flags}
                      {:id :user :machine-id :load-user-profile}
                      {:id :dash :machine-id :load-dashboards}]
   :join             :all                      ;; or :any, {:n N}, or {:fn pred}
   :on-child-done    :child/done               ;; child → parent event keyword for success
   :on-child-error   :child/error              ;; child → parent event keyword for failure
   :on-all-complete  [:assets-loaded]          ;; fires when join condition is met by completions
   :on-any-failed    [:asset-load-failed]      ;; fires when any child fails (default — see §Join semantics)
   :on-some-complete [:partial-load]}          ;; fires when {:n N} or :any is met
  :on    {:assets-loaded     :ready
          :asset-load-failed :error
          :partial-load      :degraded}}}

While in :hydrating, four child actors are alive at [:rf/runtime :machines :snapshots <gensym'd-id>]. Each child reaches a terminal state and dispatches [<parent-id> [:child/done :cfg & extra]] (or [:child/error :cfg & extra]) back. The runtime intercepts these events at the parent's machine boundary, updates the join state at [:rf/runtime :machines :spawned <parent-id> <invoke-id>], evaluates the join condition, and on resolution fires [:on-all-complete-or-friend ...] into the parent (an ordinary FSM event the parent's :on handles) AND cancels any siblings still in flight.

Spec-spec keys

The map under :spawn-all accepts the following keys:

key purpose required?
:children a vector of invoke-spec maps — same shape as :spawn (see §Spec-spec keys) plus a required :id keyword for join-state addressing required, vector of ≥ 1
:join join-condition discriminator: :all (default), :any, {:n N}, {:fn (fn [{:keys [done failed]}] ...)} optional; default :all
:on-child-done event keyword the parent's children dispatch back on success — runtime intercepts and updates join state required
:on-child-error event keyword the parent's children dispatch back on failure — runtime intercepts and updates join state required
:on-all-complete event vector the runtime dispatches into the parent when :join :all resolves with all-complete required iff :join :all
:on-some-complete event vector the runtime dispatches into the parent when :join :any / {:n N} / {:fn ...} resolves on the success-side required iff :join:all
:on-any-failed event vector the runtime dispatches into the parent when any child fails (default cancel-on-decision applies) optional; if absent, child failures are tracked but do not short-circuit the join
:cancel-on-decision? true (default) cancels siblings still in flight when the join resolves; false lets siblings run to completion (their results land in the join-state but trigger no further parent events) optional; default true

Each child invoke-spec under :children accepts the same keys as a single :spawn (:machine-id xor :definition, :data, :id-prefix, :on-spawn, :start, :spawn-id, :system-id) plus a required :id keyword that names the child for join-state addressing. The :id keyword is the user-supplied name the parent's :on-child-done / :on-child-error events carry as the second-position payload arg (see §Child completion protocol).

Wall-clock timeouts: use the parent state's :after slot. :spawn-all does not carry a :timeout-ms slot; phase-level wall-clock guards on the join are expressed via :after on the :spawn-all-bearing state. Per §Wall-clock timeouts on :spawn — use parent state's :after, an :after firing exits the state and the desugared :exit action cancels every surviving child via the standard exit cascade.

Join semantics

The runtime tracks per-state join state at [:rf/runtime :machines :spawned <parent-id> <invoke-id>] — a single map carrying both the per-:id child id map and the join-resolution metadata:

{:children {:cfg :load-config#1 :flag :load-feature-flags#2 :user :load-user-profile#3 :dash :load-dashboards#4}
 :done      #{:cfg :flag}     ;; ids of children whose :on-child-done fired
 :failed    #{}                ;; ids of children whose :on-child-error fired
 :resolved? false              ;; flips to true when the join condition resolves; subsequent events ignored
 :spec      <invoke-all-spec>} ;; the live spec map (children + :join + :on-* + :cancel-on-decision?)

Where :children is the per-:id map of user-supplied id → spawned actor id. :done and :failed are sets of user-supplied ids that have signalled completion. :resolved? is the latch that prevents a second join-event firing once the condition has been met. :spec is the snapshot of the invoke-all spec at spawn time so resolution is decoupled from later spec edits.

the [:rf/runtime :machines :spawned <parent> <invoke-id>] slot is a direct map (the :join / :children keys co-mingle at the root); there is no nested :join sub-map. The same slot stores a keyword (the spawned-actor id) for a single declarative :spawn, and a map (the shape above) for :spawn-all. Map-vs-keyword disambiguation at the destroy-resolution call site picks the right teardown path. This mirrors [:rf/runtime :machines :system-ids]'s single-level shape and keeps the addressing scheme uniform with the rest of the runtime spawn registry.

Join condition discriminators:

  • :all (default) — fires :on-all-complete once :done covers every :id. If :on-any-failed is present and any child errors, it fires immediately and the join short-circuits. If :on-any-failed is absent, child failures are tracked but the join waits for :done to cover every :id (failed children never join the :done set, so the join never resolves on success — equivalent to the failure tearing down the parent's surrounding state via a separate transition).
  • :any — fires :on-some-complete after the first :on-child-done. If :on-any-failed is present, the first child error fires it instead.
  • {:n N} — fires :on-some-complete after the Nth :on-child-done. Failures handled per :on-any-failed as above.
  • {:fn (fn [{:keys [done failed]}] truthy)} — user-supplied predicate; fires :on-some-complete when truthy.

Cancel-on-decision (default true)

When the join condition resolves — :on-all-complete, :on-some-complete, or :on-any-failed fires — siblings still in flight are cancelled by default. Each cancelled sibling has its :rf.machine/destroy fx fired (the same one the exit-cascade would fire), and the runtime emits a :rf.machine.spawn/cancelled-on-join-resolution trace event per cancelled actor. Cancellation is the right default for boot-as-state-machine: when "all assets loaded" fires, no value is added by letting an in-flight sibling continue to consume bandwidth and dispatch into a parent that has already moved on.

Apps that want non-cancelling joins (e.g. analytics fan-out where each child is independently valuable) declare :cancel-on-decision? false. In that case siblings run to completion; their :on-child-done / :on-child-error events still update the join state (the :resolved? latch already flipped, so no further parent event fires) and tools observing the join-state see the full late-completion record. The :on-some-complete / :on-all-complete semantic remains "fired exactly once when the condition was first met."

The implicit assumption: the parent's surrounding state is exited by the join-event's transition before the cancellation fx runs, so the exit cascade's standard auto-destroy machinery handles the cancellation as part of the same exit (sibling teardown is just "the exit cascade runs while N children are still alive"). This means cancel-on-decision is not a separate cancellation primitive — it composes with the existing :spawn exit-cascade behaviour.

Child completion protocol

Each child decides when it is "done" or "failed" and dispatches a 2- or 3-element event vector back to the parent:

;; In the child machine (e.g. :load-config), at a terminal state's :entry:
{:done
 {:meta  {:terminal? true}
  :entry (fn [{data :data}]
           {:fx [[:dispatch [:hydrate-flow [:child/done :cfg (:result data)]]]]})}}

The dispatched event has shape [<parent-id> [<event-keyword> <child-id> & extra]] where:

  • <parent-id> is the parent machine's id (the :rf.machine/spawn site stamps :rf/parent-id so the child can pick it up if dynamic addressing is needed; for static :spawn-all declarations the parent-id is a literal in the parent's source, so the child simply hard-codes it).
  • <event-keyword> is :on-child-done or :on-child-error per the parent's spec.
  • <child-id> is the user-supplied :id from the parent's invoke-all entry.
  • & extra is whatever the child wants to forward to the parent — typically the child's final :data slice or an error reason; the parent's join-resolution event handler can read this from the event vector that fired the join.

The runtime intercepts these events at the parent's make-machine-handler boundary. Specifically, the parent's handler checks event[1][0] (the inner event keyword) against :on-child-done / :on-child-error declared on the currently-active state's :spawn-all entry. On match, the handler:

  1. Updates the join state at [:rf/runtime :machines :spawned <parent-id> <invoke-id>] (adds <child-id> to the :done or :failed set inside the same direct map).
  2. Evaluates the join condition.
  3. If the condition resolves AND :resolved? is false: flips :resolved? true; if :cancel-on-decision? is true, emits :rf.machine/destroy fx for each in-flight sibling; dispatches the join event into the parent (:on-all-complete / :on-some-complete / :on-any-failed per the resolution kind).
  4. If the condition does not resolve: the event is treated as handled (no further parent state transition).

The intercepted event is not fed into the parent's normal :on lookup — the runtime consumes it for join bookkeeping only. Parents that need to see per-child completion separately from the join-event can declare additional :on entries for :child/done / :child/error; those entries fire only on events whose state does NOT have a :spawn-all declaring those keywords. This sounds delicate but in practice the ergonomic shape is: the user picks distinct event keywords for :spawn-all interception (commonly :spawn-all/child-done / :spawn-all/child-error) so they don't collide with the parent's own per-child observers.

Per-child terminal events still fire

The runtime intercepts :on-child-done / :on-child-error at the parent's boundary; the events still fire inside the child as ordinary terminal-state events first (the dispatch-back is the child's last act). So per-child traces (:rf.machine/transition for the child's terminal transition; :rf.machine.lifecycle/destroyed for the child's actor going away) are unchanged. The join layer is additional, not a replacement.

Spawn-id tracking

The runtime spawn registry (per §Declarative :spawn and) extends naturally for :spawn-all: [:rf/runtime :machines :spawned <parent-id> <invoke-id>] becomes a map with two slot kinds — the per-child id-map under :children, and the join-state metadata (:done, :failed, :resolved?) for the live :spawn-all instance. Pre-:spawn-all declarative-:spawn spawns continue to write the keyword <spawned-id> directly under that key (their :spawn-id resolves a leaf actor address); :spawn-all instances write the map shape. Both forms are read-disambiguated by the value type (map? vs keyword?) at the existing destroy-resolution call site.

The [:rf/runtime :machines :spawned <parent-id> <invoke-id> :children <child-id>] slot resolves to the gensym'd actor id for that child. The [:rf/runtime :machines :spawned <parent-id> <invoke-id>] slot is the join-state map (the join's :done / :failed / :resolved? / :spec keys co-mingle with :children at the root — no nested :join sub-map). On state-exit (whether by normal transition, cancellation, or any other code path), the auto-destroy cascade tears down every entry under :children and clears the slot per the lazy-allocation invariant.

Trace events

The runtime emits four :spawn-all-specific trace events:

  • :rf.machine.spawn-all/started — fires on entry to a :spawn-all-bearing state, after all N children have been spawned. :tags {:machine-id <id> :state <state> :spawn-id <prefix-path> :child-ids #{:cfg :flag :user :dash} :children {:cfg :load-config#1 ...}}.
  • :rf.machine.spawn-all/all-completed — fires when :on-all-complete resolves. :tags {:machine-id <id> :spawn-id <prefix-path> :done #{...}}.
  • :rf.machine.spawn-all/any-failed — fires when :on-any-failed resolves. :tags {:machine-id <id> :spawn-id <prefix-path> :failed-id <id> :reason <event-payload>}.
  • :rf.machine.spawn/cancelled-on-join-resolution — fires once per sibling cancelled by :cancel-on-decision? true. :tags {:machine-id <parent-id> :spawn-id <prefix-path> :child-id <user-id> :spawned-id <gensym'd-id> :join-event <:on-all-complete | :on-some-complete | :on-any-failed>}.

A :rf.machine.spawn-all/some-completed trace fires for the :any / {:n N} / {:fn ...} resolution kinds — symmetric to :all-completed but for partial-success join semantics.

Worked example — auth flow with parallel asset hydration

{:initial :authenticating
 :states
 {:authenticating
  {:spawn {:machine-id :http/post
            :data       (fn [{snap :snapshot}]
                          {:url "/api/login"
                           :body (-> snap :data :credentials)})}
   :on     {:auth/succeeded :hydrating
            :auth/failed    :idle}}

  :hydrating
  {:spawn-all
   {:children         [{:id :cfg  :machine-id :load-config}
                       {:id :flag :machine-id :load-feature-flags}
                       {:id :user :machine-id :load-user-profile}
                       {:id :dash :machine-id :load-dashboards}]
    :join             :all
    :on-child-done    :asset/loaded
    :on-child-error   :asset/failed
    :on-all-complete  [:hydrate/done]
    :on-any-failed    [:hydrate/failed]}
   :on    {:hydrate/done   :ready
           :hydrate/failed :error}}

  :ready {}
  :error {}
  :idle  {:on {:submit :authenticating}}}}

The walk-through:

  1. User submits → :authenticating spawns one :http/post child.
  2. The HTTP child posts; on success dispatches [<parent-id> [:auth/succeeded ...]] → state moves to :hydrating.
  3. Entering :hydrating triggers :spawn-all's desugared entry: spawn four children in parallel. Each child is a registered machine that fetches its own asset and dispatches [<parent-id> [:asset/loaded :cfg ...]] (or [:asset/failed :cfg <reason>]) on completion.
  4. As each :asset/loaded arrives, the runtime intercepts at the parent boundary, updates [:rf/runtime :machines :spawned :auth-flow [:hydrating] :done], and evaluates :all. Once all four :done, the runtime fires [:hydrate/done ...] into the parent → state moves to :ready.
  5. If any child fails first, [:hydrate/failed ...] fires; the runtime cancels the surviving siblings (their :rf.machine/destroy fx is emitted; :rf.machine.spawn/cancelled-on-join-resolution traces fire); state moves to :error.
  6. If the user reloads the page mid-hydration, the standard frame-destroy cascade tears down every actor (the :hydrating state's exit fires every :children destroy). The :spawn-all declaration is correct-on-every-code-path.

The key property: the parent has no per-child bookkeeping in :data. The :done / :failed sets, the :children id map, the resolution latch — all runtime-owned at [:rf/runtime :machines :spawned :auth-flow [:hydrating]]. The author writes the four child-specs and the three event hooks and the runtime handles everything else.

Composition with hierarchy and :after

:spawn-all's entry/exit actions compose with the standard hierarchical entry/exit cascading machinery just like :spawn's do — the desugar produces ordinary :entry / :exit actions that the cascade machinery picks up. :after on the same state node is the canonical way to set a wall-clock timeout on the whole join — {60000 :hydrate/timed-out} fires :hydrate/timed-out if the join hasn't resolved in 60 s; the parent's :on for :hydrate/timed-out transitions out, which exits the state and tears down all surviving children via the desugared exit cascade. Per §Wall-clock timeouts on :spawn — use parent state's :after, this is the single wall-clock-timeout mechanism on :spawn-all-bearing states; there is no second :timeout-ms surface.

A common partial-success idiom is to declare :after for the phase-level timeout and let the timeout transition land in a state whose :always checks [:rf/runtime :machines :spawned <parent> <invoke-id> :done] against a partial-success guard — the parent reads which children completed before the deadline and decides whether to proceed with degraded data or to fail outright. The cleanest expression is a separate transition out of the :spawn-all-bearing state, which the existing :after machinery delivers without any :spawn-all-specific extension.

Capability gating

:spawn-all is gated under the :actor/spawn-and-join capability per §Capability matrix. A port that doesn't claim it rejects :spawn-all at registration time with :rf.error/machine-grammar-not-in-v1. The v1 CLJS reference claims it.

Errors

:spawn-all introduces three new registration-time error categories on top of the existing :rf.error/machine-*:

  • :rf.error/machine-spawn-all-bad-shape — a child invoke-spec is missing :id or both :machine-id and :definition; or :spawn-all is not a vector; or the join-event slots are missing per the required-iff rules above.
  • :rf.error/machine-spawn-all-duplicate-id — two child invoke-specs share an :id keyword. Each :id must be unique inside the same :spawn-all block.
  • :rf.error/machine-spawn-all-with-spawn — a state node declares both :spawn and :spawn-all; the combination is rejected.

Cross-references: §Spawning for the imperative-spawn surface; §Declarative :spawn for the per-child sugar that :spawn-all extends; Spec-Schemas §:rf/state-node for the schema; Pattern-Boot for boot-flow worked examples leveraging :spawn-all for hydrate-as-spawn-and-join.

Cross-spec interactions

Retry-ownership boundary with :rf.http/managed

State machines own semantic retry; :rf.http/managed owns transport-level retry. Per Spec 014 §Boundary — transport vs semantic retry, the test for which owner applies is whether the retry decision is a function of attempt count + failure category alone (transport — owned by :retry) or depends on response body / app state / another request's outcome (semantic — owned by the machine). A state spec's :spawn of :rf.http/managed configures transport retry on the request itself; the machine's transition on the resulting :succeeded / :failed reply expresses the semantic retry — re-target to a refresh state, delay via :after before re-issuing, route to a different state on a different failure category. The two layers compose without overlap. See Pattern-Boot §Worked example — auth-machine and the retry-ownership boundary for the canonical illustration.

Privacy — :sensitive? inheritance on machine trace events

A machine is an event handler (per §Registration — the machine IS the event handler). The privacy contract therefore lands without ceremony: a reg-machine registration whose metadata carries :sensitive? true is in handler-scope for the whole duration of its drain, and every machine trace event emitted within that scope inherits the stamp per 009 §The :sensitive? registration metadata key. The runtime stamps :sensitive? true at the top level of :rf.machine/transition, :rf.machine/snapshot-updated, :rf.machine/started, :rf.machine/event-received, :rf.machine.microstep/transition, :rf.machine.timer/*, :rf.machine.spawn-all/*, :rf.machine.spawn/cancelled-on-join-resolution, :rf.machine.lifecycle/*, and every other :rf.machine/* operation emitted during the scope's drain. The committed snapshot — {:state :data :meta?} — rides the :before / :after slots of :rf.machine/transition unchanged; the stamp is the consumer's signal to drop, redact, or summarise before egress.

Three cases are worth naming. (1) Sensitive parent, spawned child without the flag. Each scope's reading is independent — when the child's transition is the in-scope handler at emit time, the child's flag governs that event (the parent's flag does not transitively widen across the spawn boundary, per 009 §The :sensitive? registration metadata key — tools that want "every event in a sensitive cascade" group by :dispatch-id and OR-reduce). (2) Sensitive :spawn-target machine. The invoked child's traces inherit from the child's own registration; the join events fired on the parent (:rf.machine.spawn-all/all-completed, :any-failed, :some-completed) inherit from the parent's registration. (3) Field-level snapshot privacy. Declare sensitive app-db slots with schema metadata {:sensitive? true} (per 009 §Schema-installed redaction) when specific keys in machine-owned data need scrubbing at trace or wire boundaries. Handler metadata still stamps the whole cascade sensitive; schema metadata is the per-slot declaration surface.

Reply patterns

xstate's sendTo + sender lets a child reply to a specific request. In re-frame, no new API: include the reply event in the request:

(rf/dispatch [:request/get-data
              {:url   "/data"
               :reply [:got-data <correlation-id>]}])

The handler dispatches :got-data (with the correlation id) when the response arrives. The drain cascade keeps the request and reply in the same atomic unit. This is just convention; document it.

Querying machines

A machine is an event handler — that's the architectural commitment. But callers (tooling, AIs, conformance harnesses, post-v1 visualisers) routinely ask "what machines are registered?" and "what is machine <id>'s definition / metadata?" Forcing every caller to reimplement "scan (registrations :event), filter by :rf/machine? true" is a tax with no upside.

The framework therefore ships two thin lookup fns — derived views over the existing event registry, not a new registry kind:

(rf/machines)
;; → seq of registered machine TYPE-ids (singletons + spawnable types)
;; Implementation: every event handler whose registration metadata
;; carries :rf/machine? true.
;;
;; NOTE (rf2-a2sn1): this enumerates registered TYPES — singleton
;; machines and the types a `:spawn` names — NOT live spawned INSTANCES.
;; A spawned actor carries no per-instance registrar entry (its liveness
;; is its snapshot; per §Liveness is derived from app-db), so it does not
;; appear here. Enumerate live instances from the snapshots map:
;;   (keys (get-in (rf/app-db-value frame-id)
;;                 [:rf/runtime :machines :snapshots]))

(rf/machine-meta :drawer/editor)
;; → registration-metadata map (transition table, doc, schemas, ...)
;; Implementation: (handler-meta :event :drawer/editor), with the
;; standard metadata-map shape; machine-specific keys (e.g.
;; :rf/transition-table) are present iff :rf/machine? is true.

(rf/machine-by-system-id :primary-request)
;; → :request/protocol#42 (the gensym'd id), or nil if no spawn
;;   under the active frame is currently bound to that :system-id.
;;   Implementation: (get-in app-db [:rf/runtime :machines :system-ids :primary-request])
;;   in the active frame's app-db. See [§Named addressing via
;;   :system-id](#named-addressing-via-system-id).

Both are pure functions over the registry. Both are JVM-runnable (they touch only the central registry). Both are stable across hot-reload because they re-read on each call.

Why a lens, not a registry kind:

  • Architectural commitment preserved. Machines remain event handlers. There is no :machine registry kind, no parallel substrate, no per-machine auto-registration. (rf/machines) is a filter call, not a separate index.
  • :rf/machine? true metadata is the discriminator. make-machine-handler carries this metadata onto the registration; reg-event-fx records it as part of the standard metadata map (per 001 §Metadata-map shape). User-written event handlers do not set this key.
  • One-line implementation. (rf/machines) is (registrations :event #(:rf/machine? %))-shaped; (rf/machine-meta id) is (handler-meta :event id). Both reuse the public registrar query API (API.md §Public registrar query API).
  • Discovery is a first-class operation. Visualisers can iterate every live machine without knowing where else to look; conformance harnesses can enumerate the suite under test; AI agents can answer "show me the machines in this app."

User-facing call sites:

(rf/machines)
;; → (:auth.login/flow :checkout/flow :request/protocol ...)
;;   registered TYPES (incl. spawnable types like :request/protocol),
;;   NOT live instances like :request/protocol#42 (rf2-a2sn1).

(for [id (rf/machines)]
  [id (-> (rf/machine-meta id) :doc)])
;; → ([:auth.login/flow "Login flow: idle → submitting → ..."]
;;    [:checkout/flow "Checkout wizard."]
;;    ...)

;; Live spawned INSTANCES — read the snapshots map (their liveness is
;; their snapshot, per §Liveness is derived from app-db):
(keys (get-in (rf/app-db-value :rf/default)
              [:rf/runtime :machines :snapshots]))
;; → (:auth.login/flow :request/protocol#42 :request/protocol#43 ...)

See also API.md §Machines.

Subscribing to machines via sub-machine

Machines are read like any other app-db slice — through a registered subscription. The framework ships :rf/machine as standard infrastructure (alongside :dispatch fx, the path interceptor, and the rest of the framework-supplied registry entries):

(rf/reg-sub :rf/machine
  (fn [db [_ machine-id]]
    (get-in db [:rf/runtime :machines :snapshots machine-id])))

Returns the whole snapshot {:state <kw> :data <map>} for the named machine, or nil if the machine is not yet initialised. The argument is just the machine-id — no varargs, no path-drilling. Granularity is the user's job via derived subs.

Two equivalent surfaces

The framework exposes two surfaces, both equivalent:

  • (rf/sub-machine :drawer/editor) — the canonical user-facing call site. Lives in re-frame.core alongside subscribe, dispatch, reg-event-fx. Single-arg; returns a Reagent reaction over the snapshot. The verb-noun name reads as "subscribe to a machine."
(defn sub-machine [machine-id]
  (rf/subscribe [:rf/machine machine-id]))

Name parse — the sub- prefix is the subscription family, NOT child-machine. Per audit-of-audits state-machines #11, sub-machine returns a reactive subscription on the named machine's state. The sub- prefix is re-frame's subscription-family verb (sibling of subscribe, sub-once) — it does NOT denote a child-machine / sub-machine relationship. Declarative child-machine binding uses :spawn, not sub-machine. If you're looking for "spawn a child actor of this state," see §Declarative :spawn; if you're looking for "read this machine's snapshot reactively from a view," sub-machine is the call.

  • (rf/subscribe [:rf/machine :drawer/editor]) — explicit registry use. The :rf/machine sub is in (registrations :sub), traceable, introspectable. Power-users and tools use this form.

sub-machine is sugar over the registered sub. Both surfaces resolve on the surrounding frame; @(rf/sub-machine :drawer/editor) reads from that frame's [:rf/runtime :machines :snapshots :drawer/editor].

;; usage in a view:
@(rf/sub-machine :drawer/editor)
;; → {:state :idle :data {:circle-id nil ...}}      (or nil before initialisation)

;; equivalent explicit form:
@(rf/subscribe [:rf/machine :drawer/editor])

The :rf/machine-has-tag? predicate sub

Alongside :rf/machine the framework ships :rf/machine-has-tag? — a predicate sub that answers the containment question for one tag without forcing the view to read (and depend on) the whole snapshot:

(rf/reg-sub :rf/machine-has-tag?
  (fn [db [_ machine-id tag]]
    (contains? (get-in db [:rf/runtime :machines :snapshots machine-id :tags]) tag)))

Arguments. Two: the machine-id keyword and the tag keyword. Both are required; neither varies — there is no varargs form, no path-drilling, no default. The sub vector is [:rf/machine-has-tag? <machine-id> <tag>].

Return contract. Strictly true | false. Returns true iff the named machine's snapshot's :tags set contains tag. Returns false for every other case — tag absent, snapshot present but :tags elided (no active state declares tags), or no snapshot at all (unknown or not-yet-initialised machine). Never returns nil; the predicate shape is total over (machine-id, tag) pairs.

Re-render granularity. The sub is derived — it reads the snapshot via get-in rather than chaining off :rf/machine — so the reaction emits only when this tag's containment-bit flips. A view that asks (rf/machine-has-tag? :ui/nine-states :data/loading) does not re-render when :state, :data, :meta, or other tags change; only when :data/loading is added to or removed from :tags. Reagent's built-in equality dedup gates the boolean return.

;; canonical sugar — single call site
@(rf/machine-has-tag? :ui/nine-states :data/loading)
;; => true | false

;; equivalent explicit form
@(rf/subscribe [:rf/machine-has-tag? :ui/nine-states :data/loading])

For the full tag-set narrative — what :tags is, how the runtime computes it at every transition, what the user-vs-runtime ownership boundary looks like — see §State tags. This section catalogues only the subscription surface.

Granularity is via derived subs

The framework provides the entry point — :rf/machine returns the whole snapshot. Users write Layer-3 (signal-graph chained) subs for fine-grained reactivity, multi-source combinations, or computed projections:

;; project just :state
(rf/reg-sub :drawer/editor-state
  :<- [:rf/machine :drawer/editor]
  (fn [{:keys [state]} _] state))

;; a boolean over :state
(rf/reg-sub :drawer/editing?
  :<- [:rf/machine :drawer/editor]
  (fn [{:keys [state]} _] (= state :editing)))

;; combine with other subs
(rf/reg-sub :drawer/editor-and-circles
  :<- [:rf/machine :drawer/editor]
  :<- [:drawer/circles]
  (fn [[ed circles] _] {:editor ed :circles circles}))

The framework provides the entry point; users write the derivations. Same pattern as every other :<- chain in re-frame.

Pure-factory invariant preserved

make-machine-handler registers nothing — it returns a pure handler fn. Registration of the machine's event handler happens at the reg-event-fx call site; reading the snapshot happens through the framework-registered :rf/machine sub. There is no auto-registration tied to the machine's id, no self-id capture, no registration side effects in the factory.

Testing

Three test levels fall out naturally from the pure-factory contract on make-machine-handler. No new primitive needed.

Level 1 — pure machine-transition

(rf/machine-transition definition snapshot event)
;; → [next-snapshot effects]

No :db, no [:rf/runtime :machines :snapshots] plumbing, no fx interpretation — just the FSM. Best for property-based testing, table-driven assertions, fastest tests. Definition + snapshot in, [snapshot, effects] out.

Level 2 — unregistered handler fn

(def handler (rf/make-machine-handler {:initial :idle :states {...}}))

;; The runtime stores snapshots at [:rf/runtime :machines :snapshots <id>], where <id> is the
;; surrounding registration's id. A Level-2 test calls the handler against the
;; canonical `app-db` shape directly:
(handler {:db {:rf/runtime {:machines {:snapshots {:drawer/editor {:state :idle :data {}}}}}}}
         [:drawer/editor [:right-click-circle some-id 30]])
;; → {:db ... :fx ...}

Tests handler-level integration (snapshot read/write at [:rf/runtime :machines :snapshots <id>], :data-to-:db lowering, fx composition) without going near the dispatch pipeline. Possible only because make-machine-handler is a pure factory — no registration, no test frame.

The handler resolves its id from the inbound event vector's first element (:drawer/editor), reads (get-in db [:rf/runtime :machines :snapshots :drawer/editor]) for the current snapshot, and writes the next snapshot back at the same location.

Level 3 — registered in a test frame

(rf/with-new-frame [f (rf/make-frame {:on-create [:my/init]})]
  (rf/reg-event-fx :my/editor {} (rf/make-machine-handler {...}))
  (rf/dispatch-sync [:my/editor [:event]] {:frame f})
  (assert ...))

Full integration — error categories, trace events, drain semantics. Required for spawned-actor patterns, because the whole point of spawn is "a new handler gets registered dynamically and the parent can dispatch to it" — bypassing the registry tests something else.

The pyramid

level what you test speed can't test
1 — machine-transition FSM logic, guards, action effect shapes fastest snapshot/db plumbing, fx integration
2 — unregistered handler fn handler-level wiring, :data lowering fast dispatch pipeline, spawn lifecycle
3 — registered in test frame full integration, spawn/destroy, cross-actor messaging slowest nothing

Worked example — Circle Drawer

The 7GUIs circle-drawer in this style. The modal-edit flow is a registered machine; canvas-add and undo/redo stay as ordinary handlers (orthogonal concerns).

(ns circle-drawer.machine
  (:require [reagent.dom.client :as rdc]
            [re-frame.core :as rf]))

;; ----------------------------------------------------------------------------
;; SCHEMA + UNDO INTERCEPTOR
;; ----------------------------------------------------------------------------

(def Circle [:map [:id :uuid] [:x :double] [:y :double] [:radius pos-int?]])
;; The :drawer/editor machine's snapshot lives at [:rf/runtime :machines :snapshots :drawer/editor]
;; — runtime-managed; not part of the :drawer schema. The runtime composes
;; [:rf/runtime :machines :snapshots]'s schema from registered machines' :data shapes; this slice
;; describes only the :drawer-owned domain state.
(def DrawerState
  [:map [:circles [:vector Circle]]
        [:undo [:vector :any]] [:redo [:vector :any]]])
(rf/reg-app-schema [:drawer] DrawerState)

(def undoable
  {:id     :undoable
   :before (fn [ctx]
             (assoc-in ctx [:coeffects :prior-circles]
                       (get-in ctx [:coeffects :db :drawer :circles])))
   :after  (fn [ctx]
             (let [prior    (get-in ctx [:coeffects :prior-circles])
                   db-after (get-in ctx [:effects :db])]
               (if (and db-after (not= prior (get-in db-after [:drawer :circles])))
                 (-> ctx
                     (update-in [:effects :db :drawer :undo] (fnil conj []) prior)
                     (assoc-in  [:effects :db :drawer :redo] []))
                 ctx)))})

;; ----------------------------------------------------------------------------
;; DOMAIN EVENT — the actual mutation lives here, not in the machine
;; ----------------------------------------------------------------------------

(rf/reg-event-db :drawer/apply-radius
  {:doc "Persist a circle's new radius. Called by the editor machine on commit."}
  [undoable]
  (fn [db [_ circle-id new-radius]]
    (update-in db [:drawer :circles]
               (fn [cs]
                 (mapv #(if (= circle-id (:id %)) (assoc % :radius new-radius) %)
                       cs)))))

;; ----------------------------------------------------------------------------
;; MACHINE — event handler IS the machine
;;
;; Inspectability bias (§Inspectability bias): non-trivial actions are named
;; in the machine's :actions map. The right-click action seeds three keys
;; derived from the event — compound enough to deserve a name. The
;; close-dialog action both emits an :fx and clears :data — also compound.
;; The drag-slider and cancel-dialog actions are single-expression :data
;; updates, so they stay inline (the escape hatch).
;; ----------------------------------------------------------------------------

(rf/reg-event-fx :drawer/editor
  {:doc "Modal-edit flow."}
  (rf/make-machine-handler
    {:initial :idle
     :data    {:circle-id nil :initial-radius nil :preview-radius nil}
     :actions
     {:begin-edit
      ;; Seed circle-id, initial-radius, and preview-radius from the right-click event.
      (fn [{[_ id radius] :event}]
        {:data {:circle-id      id
                :initial-radius radius
                :preview-radius radius}})

      :commit
      ;; Persist the previewed radius via :drawer/apply-radius and clear :data.
      (fn [{data :data}]
        {:fx   [[:dispatch [:drawer/apply-radius
                            (:circle-id data)
                            (:preview-radius data)]]]
         :data {:circle-id      nil
                :initial-radius nil
                :preview-radius nil}})}
     :states
     {:idle
      {:on
       ;; Note the event shape — the view passes the radius in the payload
       ;; rather than the machine reaching into app-db. Strict encapsulation:
       ;; cross-cutting data flows via the event vector, not via :db.
       {:right-click-circle
        {:target :editing
         :action :begin-edit}}}                              ;; resolves to :actions :begin-edit

      :editing
      {:on
       {:drag-slider
        ;; internal self-transition — no :target, so no exit/entry.
        ;; Single-key :data update, single non-branching expression — inline OK
        ;; per the inspectability-bias escape hatch.
        {:action (fn [{[_ new-r] :event}]
                   {:data {:preview-radius new-r}})}

        :close-dialog
        {:target :idle
         :action :commit}                                    ;; resolves to :actions :commit

        :cancel-dialog
        ;; Single :data clear, single non-branching expression — inline OK.
        ;; Nothing to apply — preview was never persisted.
        {:target :idle
         :action (fn [_]
                   {:data {:circle-id      nil
                           :initial-radius nil
                           :preview-radius nil}})}}}}}))

;; ----------------------------------------------------------------------------
;; DOMAIN EVENTS (orthogonal to the machine)
;; ----------------------------------------------------------------------------

(rf/reg-event-fx :drawer/initialise
  (fn [_ _]
    ;; Domain state under :drawer; the editor machine's snapshot lives at
    ;; [:rf/runtime :machines :snapshots :drawer/editor] — runtime-managed; not seeded here.
    {:db {:drawer {:circles [] :undo [] :redo []}}}))

(rf/reg-event-db :drawer/add-circle
  [undoable]
  (fn [db [_ x y]]
    (update-in db [:drawer :circles] conj
               {:id (random-uuid) :x x :y y :radius 30})))

(rf/reg-event-db :drawer/undo
  (fn [db _]
    (let [{:keys [undo circles]} (:drawer db)]
      (if (empty? undo) db
          (-> db (assoc-in  [:drawer :circles] (peek undo))
                 (update-in [:drawer :undo]    pop)
                 (update-in [:drawer :redo]    (fnil conj []) circles))))))

(rf/reg-event-db :drawer/redo
  (fn [db _]
    (let [{:keys [redo circles]} (:drawer db)]
      (if (empty? redo) db
          (-> db (assoc-in  [:drawer :circles] (peek redo))
                 (update-in [:drawer :redo]    pop)
                 (update-in [:drawer :undo]    (fnil conj []) circles))))))

;; ----------------------------------------------------------------------------
;; SUBS — preview state is *display* state, not domain state
;; ----------------------------------------------------------------------------

(rf/reg-sub :drawer/circles      (fn [db _] (get-in db [:drawer :circles])))
;; The framework-registered :rf/machine sub returns the snapshot {:state :data}
;; for any machine — we parameterise it on :drawer/editor and compose against
;; it via :<-. (Equivalently: @(rf/sub-machine :drawer/editor).)
(rf/reg-sub :drawer/editor-state :<- [:rf/machine :drawer/editor] (fn [snap _] (:state snap)))
(rf/reg-sub :drawer/editor-data  :<- [:rf/machine :drawer/editor] (fn [snap _] (:data snap)))
(rf/reg-sub :drawer/editing? :<- [:drawer/editor-state] (fn [s _] (= s :editing)))
(rf/reg-sub :drawer/can-undo? (fn [db _] (seq (get-in db [:drawer :undo]))))
(rf/reg-sub :drawer/can-redo? (fn [db _] (seq (get-in db [:drawer :redo]))))

(rf/reg-sub :drawer/circles-with-preview
  :<- [:drawer/circles]
  :<- [:drawer/editor-data]
  :<- [:drawer/editing?]
  (fn [[circles ed editing?] _]
    (if editing?
      (mapv #(if (= (:id %) (:circle-id ed))
               (assoc % :radius (:preview-radius ed)) %)
            circles)
      circles)))

;; ----------------------------------------------------------------------------
;; VIEW
;; ----------------------------------------------------------------------------

(rf/reg-view main []
  (let [circles                @(rf/subscribe [:drawer/circles-with-preview])
        ;; sub-machine returns the whole snapshot; inline-destructure it.
        {state :state ed :data} @(rf/sub-machine :drawer/editor)
        editing?               (= state :editing)
        can-undo?              @(rf/subscribe [:drawer/can-undo?])
        can-redo?              @(rf/subscribe [:drawer/can-redo?])]
    [:div.drawer
     [:div.row
      [:button {:on-click #(rf/dispatch [:drawer/undo]) :disabled (not can-undo?)} "Undo"]
      [:button {:on-click #(rf/dispatch [:drawer/redo]) :disabled (not can-redo?)} "Redo"]]
     [:svg {:width 600 :height 400 :style {:border "1px solid #999"}
            :on-click (fn [e]
                        (when-not editing?
                          (let [r (.. e -currentTarget getBoundingClientRect)
                                x (- (.. e -clientX) (.-left r))
                                y (- (.. e -clientY) (.-top r))]
                            (rf/dispatch [:drawer/add-circle x y]))))}
      (for [{:keys [id x y radius]} circles]
        ^{:key id}
        [:circle {:cx x :cy y :r radius :fill "transparent" :stroke "black"
                  :on-context-menu (fn [e] (.preventDefault e)
                                     ;; pass radius in the event payload — machine cannot read :db
                                     (rf/dispatch [:drawer/editor [:right-click-circle id radius]]))}])]
     (when editing?
       [:div.dialog {:style {:border "1px solid #999" :padding "10px" :margin-top "5px"}}
        [:p (str "Adjust diameter of circle " (:circle-id ed))]
        [:input {:type "range" :min 5 :max 100 :step 1
                 :value (:preview-radius ed)
                 :on-change #(rf/dispatch [:drawer/editor [:drag-slider
                                                           (js/parseInt (.. % -target -value))]])}]
        [:div.row
         [:button {:on-click #(rf/dispatch [:drawer/editor [:close-dialog]])}  "Commit"]
         [:button {:on-click #(rf/dispatch [:drawer/editor [:cancel-dialog]])} "Cancel"]]])]))

Modeling rule the example illustrates: preview is display state, not domain state. The drag never persists into :circles; instead the :drawer/circles-with-preview sub merges :preview-radius from the editor's :data into the rendered circles at read time. Cancel is therefore a no-op on domain state — there is nothing to revert because nothing was persisted.

Capability matrix

Per 000-Vision §Hierarchical FSM substrate, implementations declare which capabilities they support; conformance is graded against the claimed capability list rather than an all-or-nothing pass/fail. The matrix names each capability, what coverage it requires (prose / schema / fixture), and the v1 CLJS reference's claim for each.

FSM-richness axis

Capability Coverage required v1 CLJS reference Notes
Flat FSM — states, transitions, guards, actions, :entry / :exit, wildcard :* Prose: §Transition table grammar, §Action effect map; Schema: :rf/transition-table (flat); Fixtures: machine-transition.edn and the flat-FSM family ✓ claimed Already specced; the foundation.
Hierarchical compound states — nested :states in a state node; entry/exit cascading along the LCA path; vector / keyword target resolution; deepest-wins transition resolution with parent fallthrough Prose: §Hierarchical compound states; Schema: :rf/state-node (recursive) + :rf/transition-target; Fixtures: hierarchical-compound-transition, hierarchical-cross-level-transition, hierarchical-parent-fallthrough ✓ claimed (specified) Snapshot dual-form, LCA-based cascading, and deepest-wins resolution are locked.
Eventless :always transitions — fire as soon as a guard becomes true Prose: §Eventless :always transitions; Schema: :rf/state-node extended for :always (see Spec-Schemas §:rf/transition-table); Fixtures: always-single-microstep, always-depth-exceeded ✓ claimed (specified) Microstep loop inside drain Level 3; bounded depth (default 16); self-loop forbidden at registration; trace events at both per-microstep and macrostep granularity.
Delayed :after transitions — fire after a time delay Prose: §Delayed :after transitions; Schema: :rf/state-node extended for :after (see Spec-Schemas §:rf/transition-table); Fixtures: after-single-delay, after-stale-detection, after-hierarchy ✓ claimed (specified) Epoch-based stale detection — no :cancel-dispatch-later fx; clock primitives live in re-frame.interop (now-ms, schedule-after!, cancel-scheduled!); SSR-mode no-ops timer scheduling; trace events at :scheduled / :fired / :stale-after granularity.
State tags:tags <set-of-keywords> on a state node; snapshot carries the active-configuration tag union Prose: §State tags; Schema: :rf/state-node extended for :tags, :rf/machine-snapshot extended for :tags (see Spec-Schemas §:rf/state-node and Spec-Schemas §:rf/machine-snapshot); Fixtures: tags-flat-machine, tags-compound-active-path-union, tags-empty-when-no-declaration, tags-round-trip-pr-str ✓ claimed (specified) Strictly additive — the snapshot's :tags slot is elided when the union is empty. Framework sub :rf/machine-has-tag? plus the (rf/machine-has-tag? id tag) sugar covers the predicate query. Composes with hierarchical compound states (union along the active path) and — per Stage 2 — will compose with parallel regions (union across every active region). Per (Nine States Stage 1).
Parallel regions:type :parallel with multiple concurrent regions Prose: §Parallel regions; Schema: :rf/transition-table extended for :type + :regions, :rf/state-node extended for the parallel-region body, :rf/machine-snapshot's :state widened to the third arm (see Spec-Schemas §:rf/transition-table and §:rf/machine-snapshot); Fixtures: parallel-flat-two-regions, parallel-compound-region, parallel-tags-union-across-regions, parallel-broadcast-event-both-regions, parallel-spawn-scoped-to-region, parallel-after-scoped-to-region, parallel-always-cascade-per-region, parallel-initial-state-per-region, parallel-snapshot-round-trip, parallel-ssr-hydration ✓ claimed (specified) The third :state arm — a map of region-name → keyword-or-vector-path. Shared :data across regions per §9.4 (per-region encapsulation is a signal to use the N-machine substitute pattern from CP-5-MachineGuide §Substitutes). Composes with :fsm/tags (union across every active state in every region) and with :fsm/eventless-always / :fsm/delayed-after / :actor/invoke (per-region scoping; one region's :after timer doesn't fire transitions in sibling regions). Per (Nine States Stage 2).
History states:type :history pseudo-state re-entering a compound's last-active substate; shallow / deep / default-target Prose: §History states; Schema: :rf/state-node extended for the history-pseudo-state arm, :rf/machine-snapshot extended for the :rf/history slot (see Spec-Schemas §:rf/transition-table and §:rf/machine-snapshot); Fixtures: history-shallow-restores-direct-child, history-deep-restores-leaf-path, history-default-target-on-first-entry, history-per-region-parallel, history-dangling-path-falls-back ✓ claimed (specified) A targetable pseudo-state ({:type :history :deep? bool :default-target <target>}) under a compound's :states. Records last-active configuration on the compound's exit cascade into the revertible :rf/history snapshot slot (map keyed by compound declaration path, region-qualified under :type :parallel); restores via the existing LCA / entry-cascade machinery. Missing :deep? => shallow; missing :default-target => the compound's :initial; a dangling recorded path after hot reload falls back to :default-target / :initial.
Final states:final? on a leaf state terminates the machine; a :spawnd child's :final? fires the parent's :on-done with the child's :output-key-designated :data slot, then auto-destroys the child Prose: §Final states (:final? / :on-done / :output-key); Schema: :rf/state-node extended for :final? + :output-key; :rf/invoke-spec extended for :on-done; Fixtures: final-state-singleton-auto-destroys, final-state-child-fires-on-done ✓ claimed (specified) First-class :final? flag (loud, not :meta-buried). Auto-destroy is synchronous on entry to the final state. Singleton symmetry: a standalone machine reaching :final? also auto-destroys ("final means final").

Actor-model axis

Capability Coverage required v1 CLJS reference Notes
Own state + message ports — actor identity is the registered event id; the state lives at [:rf/runtime :machines :snapshots <id>] Prose: §Where snapshots live, §Strict encapsulation; Schema: :rf/machine-snapshot, :rf/runtime; Fixtures: machine-transition, machine-actor-isolation ✓ claimed Already specced.
Imperative spawn / destroy[:rf.machine/spawn ...] and [:rf.machine/destroy ...] fx (the canonical actor-lifecycle fx-ids; emitted by :spawn desugar and authored by hand inside a machine action's :fx or any user event handler's :fx) Prose: §Spawning; Schema: :rf.fx/spawn-args; Fixtures: spawn-from-action, destroy-clears-snapshot, spawn-on-spawn-callback ✓ claimed Already specced.
Cross-actor send via :fx[:dispatch [other-actor-id [:event]]] Prose: §Spawning §What spawning gives for free; Fixtures: cross-actor-send ✓ claimed Falls out of standard :dispatch fx; no new mechanism.
Declarative :spawn (sugar over spawn) — a state's :spawn translates to entry/exit actions that spawn / destroy a child actor Prose: §Declarative :spawn; Schema: :rf/state-node extended for :spawn (per Spec-Schemas §:rf/transition-table); Fixtures: spawn-on-entry-destroy-on-exit, spawn-tracked-without-data-pending ✓ claimed (specified) No new mechanics; pure sugar. make-machine-handler translates :spawn to entry/exit :rf.machine/spawn / :rf.machine/destroy at registration time. Composes with user-supplied :entry / :exit (user runs first). Per (Option A revised): the runtime tracks spawned ids at [:rf/runtime :machines :spawned <parent-id> <invoke-id>] so :on-spawn is purely advisory user-side bookkeeping — the destroy cascade no longer reads the user's :data.
Spawn-and-join via :spawn-all — first-class parallel-region state-machines: a state node declares N child actors and a join condition (:all / :any / {:n N} / {:fn ...}), the runtime fires one of three parent events when the join resolves and (by default) cancels surviving siblings Prose: §Spawn-and-join via :spawn-all; Schema: :rf/state-node extended for :spawn-all (per Spec-Schemas §:rf/transition-table); Fixtures: spawn-all-join-all-completes, spawn-all-join-any-fails-cancels, spawn-all-n-of-cancels-extras ✓ claimed (specified) Sugar over N parallel :spawns plus a runtime-owned join-state at [:rf/runtime :machines :spawned <parent> <invoke-id>] (the direct map shape per — :children / :done / :failed / :resolved? / :spec co-mingle at the root). Cancel-on-decision is the default (matches Dash8/rf8 boot-page-reload semantics).
:system-id named-machine addressing — a :rf.machine/spawn whose args carry :system-id binds the actor in the per-frame [:rf/runtime :machines :system-ids] reverse index; (rf/machine-by-system-id sid) resolves the binding Prose: §Named addressing via :system-id, §Cross-machine messaging by name; Schema: :rf.fx/spawn-args extended for :system-id; Fixtures: spawn-with-system-id-then-lookup-resolves, spawn-without-system-id-leaves-index-empty, destroy-machine-clears-system-id-index, system-id-collision-warns-and-rebinds ✓ claimed (specified) Opt-in. The reverse index lives in app-db so it inherits frame revertibility. Collisions emit :rf.error/system-id-collision and rebind (last-write-wins).
~~Wall-clock :timeout-ms on :spawn / :spawn-all~~ DROPPED in favour of state-level :after. See §Wall-clock timeouts on :spawn — use parent state's :after and MIGRATION §M-44. n/a The :after capability subsumes this; one canonical primitive, not two. The :fsm/delayed-after capability above covers wall-clock-on-state semantics for both pure timed-transition states and :spawn-bearing states.
SCXML / Stately / XState JSON interop — bidirectional schema parity or paste-and-render compatibility Out of v1 scope ✗ not claimed Deferred to v1.1+; tracked alongside (SCXML) as the v1.1+ interop family.

How conformance is graded

A re-frame2 port declares its capability list in its conformance harness manifest:

{:port-id    :re-frame-cljs
 :capabilities #{:fsm/flat
                 :fsm/hierarchical
                 :fsm/eventless-always
                 :fsm/delayed-after
                 :fsm/tags
                 :fsm/parallel-regions
                 :fsm/final-states                    ;; :final? + :on-done + :output-key
                 :fsm/history                          ;; :type :history pseudo-state — shallow / deep / default-target
                 :actor/own-state
                 :actor/spawn-destroy
                 :actor/cross-actor-fx
                 :actor/invoke
                 :actor/spawn-and-join
                 :actor/system-id}}    ;; :actor/timeout retired per  — :fsm/delayed-after subsumes it

The harness runs every fixture whose :fixture/capabilities is a subset of the port's claimed list; fixtures requiring un-claimed capabilities are skipped (and reported as "not exercised"). The aggregate score is "passes / claimed-applicable" rather than "passes / total." A port that only claims :fsm/flat + :actor/own-state + :actor/spawn-destroy is fully conformant for that subset — there is no penalty for not claiming hierarchical-states, just an honest accounting of what works.

Error category for unclaimed grammar: when make-machine-handler encounters a key whose capability is not in the host's claimed capability list, it rejects the registration — the registration does not proceed, no handler is installed, and a single structured error trace event is emitted:

{:operation :rf.error/machine-grammar-not-in-v1
 :op-type   :error
 :tags      {:category   :rf.error/machine-grammar-not-in-v1
             :failing-id <machine-id>
             :feature    <unsupported-key>             ;; e.g. :after, :type :history
             :reason     "Transition-table feature `<X>` is not in this implementation's claimed capability list. See [§Capability matrix](#capability-matrix)."}
 :recovery  :no-recovery}                              ;; registration is rejected — the handler is not installed

The disposition is reject-at-registration (:no-recovery): an unsupported key is a hard error raised at the only construction site (registration), not a trace-and-continue. This matches the neighbouring capability rows above — parallel regions, :tags, and :spawn-all all "raise at registration" when unclaimed — and is the disposition the 009 §Error contract catalogue (the error-id SSOT) records as canonical. The error is registered as a category in 009 §Error contract.

Cross-references: 000 §Hierarchical FSM substrate for the goal text; conformance/README.md for the fixture-tagging convention.

Substitutes for skipped features

Per (Nine States Stage 2), parallel regions are now a first-class capability — see §Parallel regions. The N-machines-per-region substitute documented in CP-5-MachineGuide §Substitutes remains valid and is the right answer when the regions are conceptually independent features (multiple tabs with their own state, boot phases plus diagnostics, an audio/video player whose two regions share nothing but the play/pause event). Parallel regions are the right answer when the regions are orthogonal axes of one feature that share a single :data blob (one form with three orthogonal axes, one widget with display + interaction, one page's render-mode predicates).

History states are a first-class capability — see §History states. The earlier snapshot-as-value substitute is withdrawn; there is no substitute pattern to reach for. (The N-machines-per-region substitute for the parallel case above remains valid for conceptually-independent features.)

Open questions

SA-4 classification. Per SPEC-AUTHORING §SA-4: the Globally-registered guards/actions (RESOLVED) entry and the "Auto-cleanup of orphaned actors" entry that previously lived here have both been migrated to ## Resolved decisions per SA-4's migration rule. Stately.ai / XState JSON interop is out of scope for v1; see (SCXML) for the v1.1+ interop family.

Resolved decisions

Globally-registered guards/actions vs machine-scoped (RESOLVED)

Resolved: machine-scoped. Guards and actions live in the machine's :guards / :actions maps inside the make-machine-handler spec; transition-table keyword references resolve machine-locally at registration time. There is no reg-machine-guard / reg-machine-action API and no :machine-guard / :machine-action registry kind. Cross-machine reuse is via Clojure vars (define a var; reference it from each machine's :guards / :actions map) — no framework support needed beyond ordinary var resolution. See §Registration — the machine IS the event handler and §Inspectability bias.

Library packaging — in-tree or separate? (RESOLVED)

Resolved: separate artefact, day8/re-frame-2-machines. Per (executing Strategy B), the machine substrate ships as a per-feature artefact split out of core — implementation/machines/src/re_frame/machines* with its own deps.edn and shadow-cljs build target, and core-side cross-references late-bound via the hook registry so apps not using machines pay zero bundle cost. The earlier "prototype as separate, promote to in-tree once API stabilises" recommendation is superseded: the per-feature artefact pattern (machines, schemas, routing, flows, http, ssr, epoch) is now the standard packaging shape — see Conventions.md §Packaging conventions for the catalogued split.

Eventless :always transitions — microstep loop inside drain (RESOLVED)

Resolved: :always is a state-node key holding a vector of guarded transitions; the drain cascade extends Level 3 with a microstep loop (drain :raise → check :always → loop) that settles to a fixed point before commit. Default depth limit 16, error category :rf.error/machine-always-depth-exceeded. Same-state same-guard self-loops rejected at registration with :rf.error/machine-always-self-loop. Trace events emitted at both per-microstep and outer-macrostep granularity. See §Eventless :always transitions.

Sub-event call-site shape (RESOLVED)

Resolved: the dispatch shape for events targeting a machine is the sub-event form [:machine-id [:inner-event-keyword & payload]] — the machine handler resolves the second-position inner keyword as the FSM event. The flat form ([:machine-id/inner-event payload] with one reg-event-fx registration per event) is not how machines are addressed. Why: fewer registry entries (one per machine, not one per event); call-site labels show "this is going to the editor machine"; works uniformly for spawned actors whose ids are gensym'd. See the worked examples in §Registration — the machine IS the event handler and the Circle Drawer.

Multiple machine instances at one path

Snapshots live at the runtime-managed path [:rf/runtime :machines :snapshots <id>], keyed by the registered id. Two registrations sharing an id collide at the registry layer (last-write-wins per the standard registration semantics, with a re-registration trace event); a single id never has two snapshot locations. The earlier "two machines at one :path" scenario cannot arise because users no longer pick a path. Per-frame isolation falls out of each frame having its own app-db and thus its own [:rf/runtime :machines :snapshots] map. See §Where snapshots live.

Auto-cleanup of orphaned actors — explicit :rf.machine/destroy for v1 (RESOLVED)

Resolved (per, closing): v1 requires explicit teardown via [:rf.machine/destroy <actor-id>]. Auto-cleanup via an opt-in :owned-by ownership relation (whereby an actor whose owner is destroyed is itself destroyed) is a v1.1+ direction. The explicit-destroy surface matches make-frame / destroy-frame! and keeps the v1 lifecycle model uniform: every actor's life ends at a named, traceable site. Tooling and conformance fixtures can rely on the absence of implicit-destroy cascades; ports do not need to model an ownership graph to be v1-conformant. See §Spawning — dynamic actors for the spawn / destroy surface; the :rf.http/managed-abort cascade per §Cancellation cascade — in-flight :rf.http/managed aborts is the one composed-with cascade that fires off a :rf.machine/destroy.

Spawn id format — <id-prefix>#<n> keyword (RESOLVED)

Resolved: a declarative-:spawn spawn allocates a keyword id of the form <id-prefix>#<n>, preserving any namespace on the prefix — e.g. an :id-prefix :request/protocol produces :request/protocol#1, :request/protocol#2, … The # separator is the instance-id marker and is unambiguous (Clojure keyword readers tolerate # in the name part, and no user-facing keyword convention uses it). <n> is a per-<id-prefix> monotonic integer starting at 1; the counter lives in the snapshot at [:rf/spawn-counter <id-prefix>] so allocation is deterministic from (definition, snapshot, event) (per — machine-transition is a pure function). :id-prefix defaults to the parent's :machine-id; an explicit :spawn-id bypasses allocation entirely (the actor is bound under that literal). The slash-with-numeric-tail alternative (:request.protocol/42) is rejected — it collides with the namespace/name convention every other re-frame2 keyword follows, and a trailing numeric segment is not idiomatic Clojure. The format is shared by the imperative [:rf.machine/spawn ...] fx-id allocator (whose counter lives at [:rf/runtime :machines :spawn-counter <machine-id>] in the spawning frame's app-db) and the declarative-:spawn allocator (whose counter lives in-snapshot); both produce identically-shaped ids. See Spec-Schemas §:rf/machine-snapshot for the :rf/spawn-counter slot schema and §Declarative :spawn for the allocation call sites.

Lessons from xstate (deliberate divergences)

For readers familiar with xstate, the explicit list of where re-frame2 chose differently and why — ActorRef vs snapshots, mailboxes vs the per-frame router, raise vs :raise, three-creation-modes vs one, hierarchy as data, :context vs :data, compound guards, action vectors, setup({...}) vs machine-scoped :guards / :actions, [:assign {...}] vs :data returns — lives in CP-5-MachineGuide §Lessons from xstate.

Convergences: machines-as-actors, run-to-completion, encapsulated state, snapshots, definition/implementation split, transition tables as data.

Deliberate name divergence — :spawn (NOT :invoke)

re-frame2's declarative child-actor key is :spawn, not xstate's :invoke. This is a deliberate divergence on the single most semantically-loaded machine surface. The convergence of names (:final?, :on-done, :guard, :action, :entry, :exit, :after, :always, :tags, :type :parallel, :regions, :system-id) is high enough that AI agents trained on the xstate corpus would otherwise generate almost-correct code that misses re-frame2's per-feature spec nuances. Renaming the spawn-on-entry / destroy-on-exit slot to :spawn breaks the convergence trap on the surface where the semantics diverge the most:

  • re-frame2 has no :onError sibling — failure flows through the framework's :rf.error/* machinery + :on-child-error (on :spawn-all).
  • re-frame2 has no :onSnapshot — the snapshot lives in app-db and is read by subscribing to :rf/machine.
  • re-frame2 has no per-actor mailbox — events route through the per-frame queue.
  • re-frame2's :spawn IS state-bound (destroyed on exit) by construction; xstate's :invoke shares the property but the surface invites the assumption that other lifetime patterns are also supported (they aren't in v1).

The rename also aligns the declarative key with the existing imperative fx-id :rf.machine/spawn — one verb for "make a child actor", whether declarative-via-state-node or imperative-via-fx. The runtime-stamped reserved keys follow suit: :rf/spawn-id (the prefix-path of the :spawn-bearing state node), :rf/spawn-all-id, :rf/spawn-all-child-id. Trace ops follow: :rf.machine.spawn-all/started, :rf.machine.spawn-all/all-completed, :rf.machine.spawn/cancelled-on-join-resolution, …

See §Declarative :spawn, §Spawn-and-join via :spawn-all, and migration/from-re-frame-v1/README.md §M-56.

Future

Post-v1 work that is in scope conceptually but does not ship in v1.

Diagram export from transition tables

The transition table is data; rendering it as a diagram is straightforward. v1 ships no exporter; post-v1 candidates:

  • (rf/machine->mermaid definition) — emit Mermaid stateDiagram-v2. Renders inline in GitHub markdown, VS Code preview, AI-agent prompts.
  • (rf/machine->d2 definition) — emit D2.

XState/Stately JSON export (machine->xstate-json) is part of the v1.1+ interop family, not a v1 deliverable; tracked alongside (SCXML).

Mermaid/D2 are AI-fluent — LLMs read and write them confidently — which makes diagram export the cheapest way to extend AI-amenability of machine code.

Inspector wire-format

Stately Inspector is a documented event protocol that any tool can subscribe to. re-frame2's machine traces (:op-type :rf.machine, :operation :rf.machine/transition, machine-emitted dispatches carrying :source :machine-action, :tags carrying state/event/snapshot) are already very close in shape. A post-v1 mapping document — re-frame2 trace ↔ Stately Inspector event — lets external xstate-aware tools watch a re-frame2 app for free, and lets AIs reuse vocabulary they already know.

See Tool-Pair.md for the tooling story; the Stately mapping is one consumer.

Model-based testing harness — re-frame.machines.test

A post-v1 library, planned as re-frame.machines.test, treats the transition table as a graph and generates test cases automatically. xstate's @xstate/test is the reference; re-frame2's pure machine-transition and machine-scoped :guards make the analogue cheap.

The substrate guarantees needed by the harness — all already locked in v1:

  • Pure transition function. (machine-transition definition snapshot event) is deterministic; the harness can simulate any path without running the full runtime.
  • Data-only transition tables. :states / :on / :always / :after / :spawn / :spawn-all are all readable as data; no instrumentation, reflection, or special build steps required.
  • Machine-scoped guards as functions. The harness can call :guards directly with synthesised snapshots to find inputs that make each guard true and false — generating test data, not just paths.
  • Machine-scoped actions as functions. Same property; the harness can compose action effects without runtime side effects.
  • Conformance corpus shape. Generated test cases land as EDN fixtures in the existing corpus format; same fixture exercises both the user's machine logic and any conformant implementation's machine substrate.

The harness's locked design (per the model-based-testing follow-up):

  • Default coverage model: transition coverage (every transition fires at least once). State coverage and guard coverage are opt-in; path coverage (n-step combinations) for advanced cases.
  • :after timers are included with explicit time-advance steps using the test-clock pattern from re-frame.interop.
  • Action / spawn stubbing is auto-installed by the harness (registered fxs become no-ops in test mode); recursive coverage of spawned children is opt-in.
  • Output: EDN fixtures in the corpus shape so generated tests are trace-comparable across implementations.

The harness is post-v1 because v1's substrate is sufficient — the harness builds on top without runtime changes. Two consumers will benefit:

  1. AI-implementability story — when an AI implements re-frame in a new language (per Goal 2 — AI-implementable from the spec alone and Implementor-Checklist), the harness produces a coverage corpus the implementation must pass.
  2. AI scaffolding of new machines — an AI scaffolding a new application's machine generates its test corpus before writing any test by hand; reduces missed-edge-case bugs.

See 008-Testing.md §Future for the testing-side forward-pointer.

Declarative state-scoped child machines

The post-v1 re-frame.machines library may surface a :child-machine slot on a state node that desugars to entry/exit actions which spawn / destroy a child via the standard :rf.machine/spawn / :rf.machine/destroy mechanism. No new substrate; pure sugar over the v1 surface.

Disposition

Post-v1 per 000 §Scope and roadmap. The split is on what's a foundation vs what's scaffolding on top of the foundation.

The v1 ship-list and the post-v1 follow-up are itemised below.

v1 ships the machine-as-event-handler foundation

  • (make-machine-handler spec) — pure factory returning an reg-event-fx-compatible handler fn that reads/writes the snapshot at [:rf/runtime :machines :snapshots <id>], calls machine-transition, lowers :data / :fx / :raise / :rf.machine/spawn into a standard effect map. Registers nothing, closes over no global state, does not know its own id. Spec keys: :initial, :data, :guards, :actions, :states, :on, :meta — no :path (the location is runtime-managed; see §Where snapshots live). The :guards and :actions maps declare the machine's named guard / action implementations; transition-table keyword references resolve machine-locally, validated at registration time.
  • (machine-transition definition snapshot event)[next-snapshot effects] — pure function. JVM-runnable. No re-frame dependencies; guard/action references resolve against the definition's own :guards / :actions maps.
  • The [:rf.machine/spawn ...] and [:rf.machine/destroy ...] fx for dynamic actor lifecycle (canonical surface; the v1 public fns spawn-machine / destroy-machine are dropped per MIGRATION.md §M-26).
  • The :raise reserved fx-id inside :fx (machine-internal); the :rf.machine/spawn and :rf.machine/destroy fx-ids registered globally for actor lifecycle.
  • [:rf/runtime :machines :snapshots <id>] as the reserved app-db storage scheme; :rf/machine? registration-metadata flag.
  • (rf/machines) and (rf/machine-meta id) — discovery lens over the event registry per §Querying machines.
  • The framework-registered :rf/machine parametric sub and its sub-machine wrapper.
  • Four-level drain semantics per §Drain semantics — including the gotchas listed in §Drain semantics gotchas.
  • The v1 transition-table grammar subset per §Capability matrix and §Transition table grammar.
  • The snapshot shape ({:state :data :meta?}) and the persist/restore stability invariants per §Snapshot shape.
  • Inspection trace events (:rf.machine.lifecycle/created, :rf.machine/event-received, :rf.machine/started, :rf.machine/transition, :rf.machine/snapshot-updated, :rf.machine.spawn/spawned, :rf.machine/destroyed, :rf.machine/guard-evaluated, :rf.machine/action-ran, etc. — see 009 §Trace events for the canonical emit-site list and §Trace events — guard evaluations and action runs for the guard/action payload contract).
  • The :rf.error/machine-grammar-not-in-v1, :rf.error/machine-action-exception, :rf.error/machine-action-wrote-db, :rf.error/machine-raise-depth-exceeded, :rf.error/machine-always-depth-exceeded, :rf.error/machine-always-self-loop, :rf.error/machine-unresolved-guard, :rf.error/machine-unresolved-action, :rf.error/machine-spawn-all-bad-shape, :rf.error/machine-spawn-all-duplicate-id, and :rf.error/machine-spawn-all-with-spawn error categories. (The :rf.error/machine-spawn-timeout-* categories are retired alongside :timeout-ms itself; per MIGRATION §M-44.)
  • The :rf.warning/no-clock-configured warning category (advisory; emitted when :after is exercised on a host whose re-frame.interop clock layer hasn't been wired).
  • The eventless :always capability per §Eventless :always transitions: state-node :always slot, microstep loop within Level 3 drain, default depth-16 limit, self-loop guard at registration time, dual-granularity trace events.
  • The delayed :after capability per §Delayed :after transitions: state-node :after slot accepting {<delay> → <transition-spec>} where <delay> is pos-int?, a subscription vector ([:sub-id & args] resolved through subscribe's machinery; re-resolves on subscription change per §Dynamic delay re-resolution), or (fn [snapshot] ms). Epoch-based stale detection (no :cancel-dispatch-later fx), SSR no-op rule, clock primitives in re-frame.interop (now-ms, schedule-after!, cancel-scheduled!), and the :rf.machine.timer/scheduled / :rf.machine.timer/fired / :rf.machine.timer/stale-after / :rf.machine.timer/cancelled (with :reason closed set) / :rf.machine.timer/skipped-on-server trace events. The whichever-fires-first cancellation cascade (per §Whichever fires first wins) composes with the in-flight :rf.http/managed abort contract per §Cancellation cascade — in-flight :rf.http/managed aborts. Per.
  • The state-tags capability per §State tags: state-node :tags <set-of-keywords> slot; runtime maintains the active-configuration tag union at [:rf/runtime :machines :snapshots <id> :tags] recomputed on every transition (including :always microsteps); framework sub :rf/machine-has-tag? plus the (rf/machine-has-tag? id tag) sugar; empty-union elision per snapshot-size optimisation; reserved framework namespace (:rf/* / :rf.*/*). Per (Nine States Stage 1).
  • The spawn-and-join :spawn-all capability per §Spawn-and-join via :spawn-all: state-node :spawn-all slot accepting a vector of child invoke-specs plus :join / :on-child-done / :on-child-error / :on-all-complete / :on-some-complete / :on-any-failed / :cancel-on-decision? keys, runtime join state at [:rf/runtime :machines :spawned <parent> <invoke-id>] (direct-map shape per — :children + :done + :failed + :resolved? + :spec co-mingled at the root, NO nested :join sub-map), cancel-on-decision = true by default, and the :rf.machine.spawn-all/started / :rf.machine.spawn-all/all-completed / :rf.machine.spawn-all/some-completed / :rf.machine.spawn-all/any-failed / :rf.machine.spawn/cancelled-on-join-resolution trace events. New error categories :rf.error/machine-spawn-all-bad-shape, :rf.error/machine-spawn-all-duplicate-id, :rf.error/machine-spawn-all-with-spawn.
  • The history-states capability per §History states: a :type :history pseudo-state under a compound's :states carrying :deep? (default shallow) and optional :default-target (default the compound's :initial); record-on-exit of the compound's last-active configuration into the revertible snapshot-root slot :rf/history (a map keyed by compound declaration path, region-qualified under :type :parallel); restore-on-re-entry via the existing LCA / entry-cascade machinery (deep = full leaf path, shallow = recorded direct child then :initial descent, never-entered = :default-target / :initial); dangling-recorded-path fallback after hot reload. Capability axis :fsm/history.
  • ~~The wall-clock :timeout-ms capability~~ — DROPPED. State-level :after is the canonical wall-clock-timeout primitive on :spawn / :spawn-all-bearing states. See §Wall-clock timeouts on :spawn — use parent state's :after and MIGRATION §M-44.
  • The cancellation cascade for in-flight :rf.http/managed requests per §Cancellation cascade — in-flight :rf.http/managed aborts: the :rf.machine/destroy path aborts every in-flight :rf.http/managed request the destroyed actor had issued, via the :http/abort-on-actor-destroy late-bind hook. Triggers include parent state exit, parent's :after firing, :spawn-all cancel-on-decision, frame destroy, and imperative [:rf.machine/destroy <actor-id>]. Each abort emits :rf.http/aborted-on-actor-destroy per Spec 009 §Trace events. Direct dispatches from event handlers (no spawned-actor envelope) are NOT subject to the cascade — apps that want HTTP-tied-to-state-occupancy lifetimes spawn child machines. Per.

Post-v1 — the re-frame.machines library

Richer scaffolding on top of the v1 foundation. None of the items below add a new substrate — each desugars into the v1 surface:

  • Sugar in transition tables: :child-machine declarative state-scoped child binding (desugars to entry/exit :rf.machine/spawn / :rf.machine/destroy). (Hierarchical state nodes, :always, :after, :spawn, parallel state nodes, final states with :on-done, and history states are all v1; see the v1 ship list above.)
  • XState/Stately/SCXML interop (v1.1+): machine->xstate-json converter, paste-and-render parity, Stately-Inspector wire-format mapping. Tracked alongside (SCXML) as the v1.1+ interop family.
  • Visualisation tooling: machine->mermaid, machine->d2 exporters.
  • Model-based testing harness: @xstate/test-style graph traversal over the transition table.
  • Recurring timers, wall-clock delays, pause/resume on :after — explicitly out of scope for v1; see §What :after does not include.