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
- Status: accepted (Phase 0, 2026-11-01)
- Deciders: Basil T T
- Related: feasibility report §4.2, ADR-0003
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 behaviour | Spec / XState v5 |
|---|---|
done.state.* is queued on the external queue | internal queue |
| Transitions selected in one microstep run sequentially, with no conflict preemption | optimal enabled transition set with preemption; grouped exit → actions → entry |
| Exit-order ties broken by state-id string | reverse 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’ writes | the whole assignment is computed against the pre-action context |
reenter ignored for non-self targets | reenter controls whether a descendant target exits the source |
| Parallel entry: the targeted region is entered last | document 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
- 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.
- XState v5 itself is the conformance oracle.
conformance/tools/xstate_oracleruns every fixture through the realxstatepackage 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. - Fixtures where the two oracles disagree are recorded with a
divergencenote. They document the Python bugs to fix upstream; the Rust engine follows XState. - 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. - 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
divergencefields. - 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
- Status: accepted (Phase 0, 2026-11-01)
- Related: feasibility report §5.2
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
- Dynamic tier (default).
DynContext = serde_json::ValueandEvent { ty: EventType, payload: serde_json::Value }. This is what JSON machines, the CLI, persistence and the Python-compatible snapshot format use. JSON-formassignoperates on it. - Typed tier. The engine is generic over
C: Serialize + DeserializeOwned + Clone + Send + Sync + 'static(context) and over an event typeE: Serialize + DeserializeOwned + Send + 'staticthat implementsEventType(returns its XState type string). Actions receive&mut C; guards receive&C. Thestatechart!DSL (Phase 1b) generates typed enums. - Rollback (
actionErrorPolicy = "rollback") requiresC: Clone. Documentation recommends cheap-to-clone contexts (Arc, persistent collections) for large states. - 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. - No callables inside configuration. Property-level
assignfunctions,input/outputfactories, computed delays and callablesendTotargets 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::Valuemanipulation; the typed tier is the performance path and is benchmarked separately. - Snapshot
contextround-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
- Status: accepted (Phase 0, 2026-11-01)
- Related: ADR-0001, feasibility report §4.3
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
- 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’sget_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 aspersistence/snapshot.py::structure_hashdefines it.state_idssorted,configurationsorted,taken_atas a float epoch, status strings.
- Store schemas (SQLite, Redis, SQL) reuse the Python table and key layouts and schema version markers so a single database can serve both runtimes.
- Envelope, inspector protocol and AsyncAPI output are byte-compatible as well.
- Python-generated fixtures (
conformance/cases/*.json→expected.python.snapshot,expected.python.snapshot_text,expected.python.machine_hash) are the test vectors; the Rustmachine_hashmust reproduce them exactly, the snapshot must equalsnapshotas a value andsnapshot_textas text (whitespace removed, volatile wall-clock fields zeroed on both sides), and the Python text must restore throughfrom_snapshot. - 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 ofxstate-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(inxstate-core) executes effects inline with a timer heap pumped bysendandtick.Interpreter(inxstate-tokio) maps effects onto tasks, channels andtokio::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
ActorLogicenum/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
| Topic | Choice |
|---|---|
| Edition / MSRV | Rust 2024 edition; MSRV 1.88 (bumped only in a minor release, checked by cargo msrv in CI) |
| Workspace | crates/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 |
| Lints | workspace [workspace.lints]: missing_docs, unreachable_pub, clippy pedantic + unwrap_used/expect_used in library crates (-D warnings in CI) |
| Errors | thiserror enums in libraries; anyhow/miette only in the xsm binary |
| Logging | tracing only; no println!/log in library crates |
| Serialization | serde + serde_json; SIMD parsers behind an optional feature later |
| Supply chain | cargo deny (advisories, licenses MIT/Apache-2.0/BSD/ISC/Unicode/Zlib, bans on duplicate major versions), cargo audit |
| Tests | unit + doctests, proptest, insta, conformance fixtures; coverage via cargo llvm-cov with a 90% gate once Phase 1 lands |
| Benchmarks | criterion + 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 |
| Release | cargo release, cargo dist for xsm binaries, cargo semver-checks before publishing |
| CI | GitHub Actions: fmt, clippy, test (ubuntu/windows/macos), msrv, deny, audit, docs, conformance oracles (Node + Python) |