Design decisions (ADRs)

The five architecture decision records below are folded in verbatim from docs/adr/. They record the choices made in Phase 0 and remain the source of truth for the engine’s semantics, data model, wire compatibility, sans-I/O design and quality gates. The historical feasibility report predates them.

ADR-0001: Engine semantics follow SCXML / XState v5, not the Python engine’s divergences

Context

The Python engine (xstate-statemachine 0.12.0) implements the SCXML algorithm but, as the source inventory showed, diverges from the W3C SCXML Appendix D algorithm and from XState v5 in a handful of undocumented places:

Python behaviourSpec / XState v5
done.state.* is queued on the external queueinternal queue
Transitions selected in one microstep run sequentially, with no conflict preemptionoptimal enabled transition set with preemption; grouped exit → actions → entry
Exit-order ties broken by state-id stringreverse document order
Every candidate guard is evaluated (memoised)evaluation stops once a transition is selected for a leaf
assign applied in place; later property functions see earlier keys’ writesthe whole assignment is computed against the pre-action context
reenter ignored for non-self targetsreenter controls whether a descendant target exits the source
Parallel entry: the targeted region is entered lastdocument order
v4 event names (done.invoke.*, error.platform.*)v5 names (xstate.done.actor.*, xstate.error.actor.*)

The headline promise of the port is “design the chart in the Stately editor, run it unchanged, behave exactly as the editor’s simulator (the real XState v5 engine) does”.

Decision

  1. The Rust engine implements the W3C SCXML Appendix D algorithm as realised by XState v5. Where SCXML and XState differ, XState v5 wins, because Stately’s simulator is XState.
  2. XState v5 itself is the conformance oracle. conformance/tools/xstate_oracle runs every fixture through the real xstate package under Node; the Rust engine must reproduce its state values, context and user-action order. The Python engine is a secondary oracle, used for snapshot/hash/wire-format vectors (ADR-0003), not for transition semantics.
  3. Fixtures where the two oracles disagree are recorded with a divergence note. They document the Python bugs to fix upstream; the Rust engine follows XState.
  4. Event names: the engine emits the v5 spellings in memory and accepts the v4 spellings on input (on: {"done.invoke.x": ...}) as aliases, so existing Python-authored charts keep working. Snapshots and the structure hash use the v4 / Python spellings (after.*, done.state.*, done.invoke.*, error.platform.*) because those are the bytes the Python library reads and hashes (ADR-0003); the engine normalises them on restore.
  5. No engine_compat = "python" switch is built unless a concrete need appears.

Consequences

  • A small number of Python parity tests encode Python-specific behaviour and will not port unchanged; they are tracked in the fixture divergence fields.
  • The Python library should receive coordinated fixes for the items above in a minor release so that both engines converge; until then, shared stores work (snapshots are state, not semantics) but a mixed fleet may order some internal events differently.

ADR-0002: Two-tier context and event model

Context

Python’s context is a mutable dict mutated in place by actions; events are duck-typed objects with .type and .payload. XState JSON (assign, input, output, params) is written in terms of JSON values. Rust users expect typed contexts and events, but a JSON-driven engine (the Stately workflow, shared stores with Python, the CLI simulator) needs a dynamic representation.

Decision

  1. Dynamic tier (default). DynContext = serde_json::Value and Event { ty: EventType, payload: serde_json::Value }. This is what JSON machines, the CLI, persistence and the Python-compatible snapshot format use. JSON-form assign operates on it.
  2. Typed tier. The engine is generic over C: Serialize + DeserializeOwned + Clone + Send + Sync + 'static (context) and over an event type E: Serialize + DeserializeOwned + Send + 'static that implements EventType (returns its XState type string). Actions receive &mut C; guards receive &C. The statechart! DSL (Phase 1b) generates typed enums.
  3. Rollback (actionErrorPolicy = "rollback") requires C: Clone. Documentation recommends cheap-to-clone contexts (Arc, persistent collections) for large states.
  4. Logic is registered by name in MachineLogic<C, E> (actions, guards, services, delays). Missing implementations are detected when the machine is built, not at run time.
  5. No callables inside configuration. Property-level assign functions, input/output factories, computed delays and callable sendTo targets exist only as registered logic, never as config values. JSON stays pure data, as in XState v5.

Consequences

  • Dynamic-tier action cost is dominated by serde_json::Value manipulation; the typed tier is the performance path and is benchmarked separately.
  • Snapshot context round-trips through serde in both tiers, so persistence is tier-agnostic.

ADR-0003: Byte-compatible snapshots, store schemas and wire formats with the Python library

Context

The Python library persists interpreter state as snapshot layout v4 (version, machine_id, machine_hash, machine_version, taken_at, status, context, state_ids, value, configuration, history, output, error, chain_trips, last_chain_error, deferred, pending_events, scheduled_sends, deadlines, actors, system) in stores with fixed table/key layouts (SQLite statecharts/deadlines, Redis snap:{key} hashes, SQL xsm_* tables). It also speaks fixed wire formats: CloudEvents envelopes, the Stately Inspector protocol pinned to @statelyai/inspect 0.7.2, AsyncAPI 3.0 documents.

A Rust worker that can resume a Python-created instance (and vice versa) is the strongest migration story and the main differentiator over a greenfield Rust statechart crate.

Decision

  1. The Rust snapshot type is layout v4, compatible with Python’s: the same fields in the same order with the same values, so that Snapshot::to_json() equals the text of Python’s get_snapshot() once the insignificant whitespace is removed (Python indents by 2, Rust writes compact JSON). The structure hash is byte-identical. Compatibility includes:
    • machine_hash: first 16 hex chars of SHA-256 over the canonical, sorted-key JSON of the per-node structural shape, exactly as persistence/snapshot.py::structure_hash defines it.
    • state_ids sorted, configuration sorted, taken_at as a float epoch, status strings.
  2. Store schemas (SQLite, Redis, SQL) reuse the Python table and key layouts and schema version markers so a single database can serve both runtimes.
  3. Envelope, inspector protocol and AsyncAPI output are byte-compatible as well.
  4. Python-generated fixtures (conformance/cases/*.json → expected.python.snapshot, expected.python.snapshot_text, expected.python.machine_hash) are the test vectors; the Rust machine_hash must reproduce them exactly, the snapshot must equal snapshot as a value and snapshot_text as text (whitespace removed, volatile wall-clock fields zeroed on both sides), and the Python text must restore through from_snapshot.
  5. Event-name aliasing (ADR-0001) is applied when restoring a snapshot whose queued events use v4 names, so old snapshots stay loadable.

Consequences

  • JSON float/string formatting must match Python’s json.dumps(sort_keys=True) for the hash input. A dedicated canonical-JSON writer is part of xstate-core.
  • Any future layout change (v5) is coordinated across both libraries.

ADR-0004: Sans-I/O step engine shared by the sync and async interpreters

  • Status: accepted (Phase 0, 2026-11-01)

Context

Python shares one async def algorithm between its sync and asyncio engines by driving the coroutine with a single coro.send(None) and requiring that it never suspends. Rust has no equivalent trick without duplicating the algorithm or forcing an async runtime on sync users.

Decision

xstate-core exposes a pure step function:

fn step(machine, &mut ActiveState<C>, event, logic, &mut Effects) -> Result<StepReport>

It performs selection, exit/transition/entry, eventless settling and onDone propagation, and appends side effects to Effects instead of performing them: ArmTimer, CancelTimers, StartInvoke, StopActor, Enqueue(event, lane), Emit, SendTo, Log.

  • SyncInterpreter (in xstate-core) executes effects inline with a timer heap pumped by send and tick.
  • Interpreter (in xstate-tokio) maps effects onto tasks, channels and tokio::time.
  • The pure API (initial_transition, transition) is the step function with effects discarded.
  • Graph utilities, the CLI simulator and the conformance harness drive the step function directly with a SimulatedClock.

Consequences

  • One algorithm, one test surface; the core crate has no I/O and compiles to wasm32.
  • Services/actors are an ActorLogic enum/trait implemented by the drivers; the core only knows “start invoke X with input Y” and “deliver done/error event”.

ADR-0005: Workspace, toolchain and quality gates

  • Status: accepted (Phase 0, 2026-11-01)

Decision

TopicChoice
Edition / MSRVRust 2024 edition; MSRV 1.88 (bumped only in a minor release, checked by cargo msrv in CI)
Workspacecrates/xstate-core, crates/xstate-tokio, crates/xstate-persistence, crates/xstate-testing, crates/xstate-conformance (dev-only), crates/xsm (CLI). Later phases add xstate-macros, xstate-eda, xstate-patterns, xstate-observability, xstate-inspect, xstate-axum, xstate-agents, xstate-py
Unsafe#![forbid(unsafe_code)] in every crate
Lintsworkspace [workspace.lints]: missing_docs, unreachable_pub, clippy pedantic + unwrap_used/expect_used in library crates (-D warnings in CI)
Errorsthiserror enums in libraries; anyhow/miette only in the xsm binary
Loggingtracing only; no println!/log in library crates
Serializationserde + serde_json; SIMD parsers behind an optional feature later
Supply chaincargo deny (advisories, licenses MIT/Apache-2.0/BSD/ISC/Unicode/Zlib, bans on duplicate major versions), cargo audit
Testsunit + doctests, proptest, insta, conformance fixtures; coverage via cargo llvm-cov with a 90% gate once Phase 1 lands
Benchmarkscriterion + budgets.json perf gate (relative, like Python’s perf_gate.py)
Docs#![deny(rustdoc::broken_intra_doc_links)], README included as crate docs, mdbook guide later
Releasecargo release, cargo dist for xsm binaries, cargo semver-checks before publishing
CIGitHub Actions: fmt, clippy, test (ubuntu/windows/macos), msrv, deny, audit, docs, conformance oracles (Node + Python)