Changelog
Changelog
All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. Every crate of the workspace shares one version number. The minimum supported Rust version (MSRV) is 1.88 and is raised only in a minor release.
Unreleased
Added
- First-class Plugin / Observer architecture (
xstate-coreandxstate-statemachine):- Added
Plugin<C>trait alias forObserver<C>andinterp.plugin(Arc::new(...))onSyncInterpreter. - Added
xstate_statemachine::pluginsmodule exposingObserver,Plugin,TracingPlugin,MetricsPlugin,InspectorPlugin,AuditLogPlugin, andBudgetPlugin. - Exported
TracingPlugin,MetricsPlugin,InspectorPlugin, andAuditLogPlugininxstate_statemachine::prelude.
- Added
- Dedicated Plugins & Observability Guide (
docs/book/src/guide/plugins.md):- In-depth Observer pattern design, complete hook reference table (
on_start,on_event,on_event_dropped,on_error,on_stop). - Step-by-step custom plugin authoring guide (audit trail, latency monitors, dead-letter capture).
- Production error containment, non-reentrancy rules, and thread-safety invariants.
- In-depth Observer pattern design, complete hook reference table (
- Ecosystem Integrations Documentation Suite (
docs/book/src/integrations/):- Overview & “Pick Your Path” decision matrix connecting Web × Store × Broker × Worker choices.
- Dedicated production guides for high-download Rust crates:
- Axum Web: REST, SSE, WebSockets, RFC 9457 Problem Details, Stately Inspector (
integrations/axum.md). - Actix-web:
web::Dataactor management, statechart HTTP handlers, SSE streams (integrations/actix.md). - Tower Middleware:
tower::Service&tower::Layerfor state-guarded endpoints and circuit breakers (integrations/tower.md). - SQLx Persistence: PostgreSQL advisory locks, SQLite WAL, idempotency inboxes (
integrations/sqlx.md). - SeaORM Async ORM: ActiveModel lifecycle hooks, state column casting, snapshot JSON persistence (
integrations/sea-orm.md). - Diesel Compile-Time ORM: Custom
ToSql/FromSqlstatechart types, durable outbox tables (integrations/diesel.md). - Redis & Redis Streams: Snapshots, Redlock distributed locking, stream pub/sub (
integrations/redis.md). - Tracing & OpenTelemetry: Structured spans, parent-child context propagation, sensitive credential redaction (
integrations/tracing.md). - Prometheus & Metrics:
metricsfacade counters, histograms, transition throughput (integrations/prometheus.md). - Schema Validation: Context and event validation using
validatorandgarde(integrations/validation.md). - Testing & Proptest Harness: Simulated clocks, path walkers, mock logic, property-based invariants (
integrations/testing.md). - Background Workers & Queues: Driving statecharts with Tokio worker loops and
DueTimerScanner(integrations/workers.md).
- Axum Web: REST, SSE, WebSockets, RFC 9457 Problem Details, Stately Inspector (
- Four additional production recipes in the Cookbook (
docs/book/src/cookbook/):- WebSocket Reconnection & Heartbeat (
cookbook/websocket-reconnect.md). - Stripe Webhook Payment Gateway with signature validation and idempotency (
cookbook/stripe-webhooks.md). - Form Wizard & Multi-Step Onboarding with back/next navigation and step validation (
cookbook/form-wizard.md). - Feature Flag Progressive Rollout with canary ramping and instant automated rollback (
cookbook/feature-flag-rollout.md).
- WebSocket Reconnection & Heartbeat (
- Comprehensive architecture and competitor comparison guide (
docs/book/src/guide/comparisons.md) benchmarkingxstate-statemachine-rsagainst Rust FSMs (statig,smlang), Python (python-statemachine,transitions), JavaScript (XState v5), and distributed orchestrators (Temporal,AWS Step Functions). - End-to-end Cookbook and Example Gallery (
docs/book/src/cookbook/) containing eight production-ready scenarios with statechart topologies, full copy-pasteable Rust implementations, and verification tests: traffic light controller, hierarchical order fulfillment, circuit breaker with exponential backoff and jitter, distributed e-commerce saga with automated compensation rollbacks, durable SQLite/PostgreSQL persistence with crash recovery, transactional outbox CloudEvents messaging over brokers, Axum HTTP service with live Stately Studio inspection, and LLM agent tool-calling loops with budget guardrails and human approval. - Top-level
README.mdredesigned with hero SVG visuals, mobile responsive presentation, complete competitor matrix, verified nanosecond benchmarks (S1–S8), and visual Stately statechart diagrams. - Redesigned GitHub Pages documentation site with premium dark OLED (
#05050A) Stately design system:- Sticky glass top navigation with logo, version badge, navigation links,
Ctrl+Ksearch modal, and dark/light theme toggle. - High-impact landing page with ambient glowing aurora gradients, release pill, live animated SVG state machine frame (
order.machine.json), 12-card bento grid, 4-way code showcase tabs, and Criterion benchmark table. - 3-column documentation layout with grouped category navigation, compile-time glowing active pill indicators (
●), dynamic reading time badges, styled code blocks with language pills and copy buttons, callout blocks, and client-side Mermaid statechart rendering. - Dynamic “On this page” TOC right sidebar auto-populated from section headings with scroll spy.
- Sticky glass top navigation with logo, version badge, navigation links,
- Configured
.cargo/audit.tomlto align cargo-audit advisory ignores withdeny.toml. - Updated
LICENSEcopyright notice to 2026.
Changed
-
Battle-test suites now cover every remaining crate in the workspace (§6.2):
xstate-eda: CloudEvents 1.0 attribute validation and depth/size bounds, dispatcher failure modes, transactional outbox leases under concurrent relays, and 4 live broker adapters (Redis Streams, NATS JetStream, RabbitMQ, AWS SQS on ElasticMQ) with bidirectional Python interop (230 tests pass).xstate-patternsandxstate-agents: retry jitter distributions over 10,000 samples, 100-thread circuit breaker contention, 20-step sagas with tokio step timeouts, prompt injection immunity, secrets masking in context and trace, and tool output truncation (153 tests pass).xstate-macros,xstate-inspect, andxstate-observability: 27 UI compile-fail diagnostic tests, clippy pedantic clean macro output, deep/wide charts, deterministic SSE fuzzing, 128-connection cap enforcement, and Prometheus 0.12.1 metric/label parity (131 tests pass).
-
Performance baselines were re-measured on the same quiet machine with the Python 0.12.1 oracle: Python S1–S7 are 18.69 / 11.42 / 36.06 / 47.66 / 115 / 45.2 / 190 µs per operation and the Rust p50 is 106 / 161 / 215 / 283 / 4.11 µs / 300 ns / 3.39 µs. S1, S3, S4 and S6 are past 100x Python (177x, 168x, 169x, 151x), S2 is 71x, S7 56x and S5 28x. The README tables,
benches/README.mdandbenches/budgets.json(python_per_op_ns, baselines and 100x targets) now carry these values; the retracted 0.12.0 numbers are gone. -
xstate-tokio’s test suite now carries the §6.2 actor matrix: paused-clock timers and cancellation, every mailbox overflow policy, sends to finished/stopped actors, child and invoke lifecycles,systemIdcollisions, panic containment, restart/drain and observers (32 tests pass). -
xstate-axum’s test suite now carries the §6.2 HTTP battle matrix: problem-details leak checks, Origin/content-type edges, 408/413, authorization-before-existence, resident persistence, per-event WebSocket authorization, build-time option validation and inspector token precedence (73 tests pass). -
The documentation site builds:
docs/book/is now a 35-page mdBook folding in the existing guides, ADRs, crate READMEs, CONTRIBUTING and CHANGELOG, with a stdlib link checker (scripts/linkcheck.py) and apages.ymlworkflow that publishes the book plus rustdoc under/api/. It has never run (the GitHub remote does not exist yet). -
xstate-coregains afast-pathcargo feature (on by default) gating the single-transition fast path;xstate-conformanceforwards it and CI now runscargo test -p xstate-conformance --no-default-features, which exercises every conformance case and golden on the general microstep. -
xstate-persistence’s test suite now carries the §6.2 battle matrix: 50 pessimistic and 50 optimistic concurrent writers, lock release on panic, durable timers fired from a second store instance, everyRestartTimersvariant, FileStore Windows path edges, 50-way inbox claims, scope escaping, and ~400 KB / unicode / stale-version cross-language cases against Python 0.12.1 (384 tests with the Docker services up). -
xstate-corenow pinsmaxIterationsbehaviour in tests: a self-raisingalwayschain stops withError::RunawayChain(and a failedstart()ends stopped), while a context-changingalwaysthat settles within the budget runs to completion. -
xstate-corenow fuzzes its parser in-process: proptest feeds arbitrary JSON values and arbitrary text throughMachineConfig::from_value,Machine::from_value,Machine::from_jsonandSnapshot::from_jsonand asserts they never panic (2000 cases per run, in CI). -
xstate-core’s test suite now carries the 17-machine fast-path adversarial matrix and the 34 adversarial direct-compiler documents from the QA audit, so they run in CI instead of living in a scratch copy. -
xstate-persistence:FileStoreno longer rewrites a lock file’s holder stamp on every acquisition. The stamp (<pid> <wall time>, from byte 1 of the.lockfile) is read first and left alone when it already names the acquiring process and is younger thanstale_lock_after / 2, so the common case — one process taking its own lock again and again — skips a write that an on-access antivirus rescans when the handle closes (matching the Python library’sFileStore._stamp_is_fresh; the OS lock stays the source of truth). A stamp by another pid, an unparseable one, an older one or one dated in the future is rewritten as before (the Python side released this change as 0.12.1 on 2026-11-07). -
Conformance fixtures were regenerated against Python 0.12.1 (released 2026-11-07), and CI now pins the Python oracle to
xstate-statemachine==0.12.1instead of installingmain. The regeneration changed only the recorded Python version string in the 53 case files — zero behavioural differences (53 cases, 0 divergent, 0 stale).
Added
xstate-core:benches/python_parity.rsmirrors the Python library’s cross-library benchmark (scenarios S1 to S7 ofbenchmarks/competitors/, 0.12.0 sync engine) with the same machines, logic and per-operation work; the ids (py_s1_flat_toggle…py_s7_delayed_transition) are inbenches/budgets.jsonwith the Python time per operation (python_per_op_ns, a new optional field ofbudgets.schema.json) and a 100x target.scripts/perf_gate.py(standard library only): runs the engine benchmarks (or readstarget/criterionwith--no-run), compares each median withbaseline_p50_ns x allowed_slowdown, prints the speed-up over Python, and exits 1 on a regression;--relativedivides out the machine speed factor,--advisorynever fails,--update-baselinesrecords the medians. CI’sbench-gatejob now runs it with--advisory --relative(hosted runners are too noisy to gate on).
Fixed
xstate-eda: a panickingDeadLetterSink::putinsideInboundDispatcher::handleis now caught and mapped toFlow::Requeueinfrastructure failure rather than escaping the dispatcher and crashing the caller task.xstate-persistence: PostgreSQL test schema creation inbattle_postgresuses an atomic counter to prevent schema name collision across concurrent test threads.- Repository hygiene: updated
async-executorfrom yankedv1.14.0tov1.14.1, ignoredlibsqlite3-sysadvisoryRUSTSEC-2026-0459indeny.toml(requires Rust 1.89 via rusqlite 0.41, exceeding MSRV 1.88; local crafted database attack vector), and dropped unusedMPL-2.0allowance. xstate-tokio: a panic in an action, guard, timer effect or service no longer kills the actor task silently. The panic is contained: the actor entersStatus::Errorwith the panic message and tears its children/services down, and a panicking async service reaches the parent aserror.actorinstead of hanging the invoke.send_and_waitnow applies the overflow policy (Reject→LimitExceeded,DropNewest→ drop withLimitExceeded) instead of always blocking.ActiveState::failis the new core helper for drivers that contain a panic.xstate-axum: a request body carryingidempotency_keyis refused with 422, matching Python’s adapter-owned payload keys. It previously reached the idempotency inbox and could deduplicate a request without the validatedIdempotency-Keyheader (bypassing its length and charset checks). A bodyidremains ordinary event data.- Documentation: the migration guide records that
MachineLogic::validatereports the first missing implementation (Python lists every name), and maps the Python-only utilities (has_dormant_timers,Clock.pump_filtered,SQLiteStore.close,register_global) to their Rust structural answers. - Documentation: the persistence parity mapping now names the real counterparts
(
IdempotencyGuardis the PythonIdempotencyPlugin’s decision logic; adoption isadopt::from_state_ids, not anadoptsymbol), the coverage claim is limited to the static targets that exist (there is no runtime collector), and the README parity table targets Python 0.12.1. - Documentation: the migration guide no longer claims that
invokeinput/outputfactories exist — they are a recorded parity gap (invoke.inputis static JSON; Python 0.12.0’s event-driven input factory andoutputfactories are not ported) — and its engine-semantics section now records that the XState v5 convergence shipped in Python 0.12.0 rather than an unreleased branch. The port target is stated as Python 0.12.1. xstate-axum: the same-origin check compares theOriginhost withHostcase-insensitively, matching Python’s sharedorigin_is_allowedrule; a mixed-caseHostheader was a false refusal. Allow-listed origins still match exactly.xstate-eda: a settle (ack/drop_message) that the transport fails now leaves the delivery in flight, so the caller can retry it with the sameDeliveryinstead of getting a silent no-op; and the RabbitMQ transport releases its un-acked slot even when the broker rejects the settle, so a failed ack can no longer shrinkprefetchuntilfetchstalls.xstate-core/xstate-persistence: redaction now matches Python 0.12.0 exactly. The core denylist gainedoauth,creditcardandcardnumber(they were leaking through the inspector, dead-letter and outbox redaction), and the transition-log observer no longer keeps its own pre-0.12.0 copy of the rule — it delegates toxstate_core::redact::RedactKeys— so ordinary keys such asshipping,company,authorandspinnerare no longer over-redacted.xstate-persistence:list_keyson the SQLite and PostgreSQL stores now drops keys thatload()would refuse before applyingLIMIT, matching the Redis store. A foreign writer that planted an empty key (which sorts first) used to consume the limit slot, solist_keys(_, 1)could return no valid key at all.SECURITY.mdnow names a working private-reporting channel (GitHub’s private vulnerability reporting) instead of an author email the Cargo manifest does not contain, and links a real threat-model page (docs/book/src/operations/security.md) covering the parser, snapshot restore, EDA envelopes, the SSE inspector and the axum surfaces.conformance/tools/agents_golden.pywrites its golden files with\nline endings, so regenerating them on Windows no longer leaves the working tree dirty.xstate-core: a machine document nested deeper than 128 state levels (root counted as 0) is refused withError::LimitExceededinstead of overflowing the thread stack (an uncatchable process abort) when a programmatically builtValueis compiled; both the direct compiler and the parser enforce the bound, and the direct compiler falls back to the parser’s typed error (serde_json’s own 128-level cap already protects JSON text).xstate-core:Machine::to_json/to_json_stringno longer returnnullfor a source document nested past serde_json’s 128-level reparse limit. The stored text was written by this process, so it is reparsed with the recursion limit disabled (JSON text from callers keeps the default limit).xstate-core: a statetypeoutsideatomic/compound/parallel/final/history("Final","paralel") and acompound/parallelstate without child states areInvalidConfigerrors naming the state and value; anatomic/final/historystate that declares children is built as compound with aConfigWarning(afinalone used to be an error, acompoundone without children silently became atomic).xstate-core: a history state’s defaulttargetmust be a non-empty string; an invalidhistoryvalue names the state.xstate-core:strict,strictTargets,strictConfigand the transition flagsreenter/internalaccept onlytrue,falseornull(“not set”); a non-booleanstrictConfigwas silently ignored andnullwas refused.xstate-core: configuration errors from action, guard and transition parsing name the state and key (State 'm.a' entry: ...,Transition 'GO' on state 'm.a': invalid 'guard': ...,State 'm.a' on 'GO': invalid transition item ...).xstate-core: actionparamsmust be an object (ornull) for every action, not only built-ins. Unknown keys of action objects, of guard objects and of a built-in’sparams("delya") are warnings, or errors understrictConfig(an unknown action-object key was always an error); a built-in written flat ({"type": "raise", "event": ...}) is refused with a hint to nest its parameters underparams.xstate-core:maxIterationsaccepts an integral float (5.0) and still refuses0, negatives,2.9,trueandnull;spawnBlockingTimeoutmust be a finite number>= 0.xstate-core: aninitialnaming no child is refused at parse time with the list of children and a “did you mean” hint, as XState’screateMachinedoes.xstate-core:afterkeys must be whole, non-negative milliseconds or a delay name. Numeric strings are normalised ("1e3"is 1000 ms," 100"is 100 ms) and the timer event, the structure hash, snapshots and diagrams use the normalised value (xstate.after.1000.<id>, as XState and Python do);"1.5","-5"and an empty key are refused, and two keys naming the same delay are an error."inf"/"NaN"are delay names, no longer numbers.AfterDef::raw_keykeeps the key as written. Pinned by conformance case 053.xstate-core: invokeid/systemIdmust be non-empty strings and an invokedescriptiona string; an empty-stringtargetis refused at parse time with the state named.xstate-core: a user action named like a built-in (log,cancel,stop,emit, …) wins over the built-in: when the logic implements it, the engine runs the user action, and the newMachine::from_value_with_logicexempts such names from the built-in’sparamscheck.MachineLogic::action_nameslists the registered actions;MachineLogic::validatenow also checks named delays of delayedraise/sendTo.xstate-core: dotted ids resolve: a custom state id"my.id"can be targeted as#my.idand#my.id.child(longest dotted prefix), andMachine::state_by_idaccepts the same forms.xstate-core: restoring a snapshot whose pending or deferred event record has an unknownkind, a non-objectpayload, notypeor a non-numeric timer timestamp fails withError::Snapshotinstead of restoring it as user traffic;done/error/afterrecords marked"engine": truerestore as engine events.xstate-core: an action failing insidechoosefollowsactionErrorPolicyexactly like a top-level one (undercontinuethe rest of the enclosing list is skipped as well), and theTransitionFailederror names the innermost failing action instead of wrapping it twice.xstate-core: a delay that resolves to NaN or infinity, or whose delay function fails, disables that one timer with an error log on every path (after, delayedraise, delayedsendTo) instead of failing the step; an unregistered delay name is stillImplementationMissing.xstate-core: a JSONassignon a context that is not an object (an array, a number) is an action error handled byactionErrorPolicyinstead of silently replacing the context with{}.xstate-core:sendTo/forwardTowith no live target andsendParent/escalatewithout a parent drop the event withon_event_dropped("unresolved_target")and mark the step failed: the transition commits,Receipt::error/StepReport::soft_errorcarry the newError::UnresolvedTarget(ErrorKind::UnresolvedTarget).SyncInterpreterhas no actor system, so every such send is unresolved there; a delayedsendTotoselfis now a timer of the machine instead of being dropped.xstate-core:send("")andEvent::from_jsonwith an empty type areInvalidEvent.xstate-core:SyncInterpreter::stopreports every queued event withon_event_dropped("stopped")instead of discarding it.xstate-core:SimulatedClock::try_increment/try_setrefuse NaN and infinity (increment/setignore them with an error log) andSyncInterpreter::advancereturns the error; the interpreter module’s snapshot and restore code moved tointerpreter/restore.rs.xstate-core: Mermaid / PlantUML exports draw every alternative of a state-levelonDone, as Python 0.12.0 does (conformance case 051 pins that every alternative is a candidate).xstate-tokio: asendToto a child and a child’s stop are put in its mailbox at once, in effect order, so an exit action’ssendToreaches the state’s own invoke before it is stopped (XStateexitStates); they were two racing tasks.xstate-tokio: unresolvedsendTo/sendParent/escalate(delayed ones included) are reported like onSyncInterpreter(unresolved_target,Receipt::error); a delayedsendTotoselfis a cancellable local timer and a delayedsendParentreaches the parent (both were dropped);send("")isInvalidEvent; events still in the mailbox when the actor stops are reported as dropped (stopped) and their waiters get an error.xstate-core:redact/RedactKeysfollow the Python 0.12.0 matching rule: keys are normalised tosnake_case(camelCase boundaries split,-as_, case-insensitive) on both sides, and denylist entries of at most four characters (pin,pan,otp,card,auth,seed, …) match only a whole word of the key, soshipping,company,authorandspinnerare no longer redacted whileuser_pin,userPinandcardNumberstill are; longer entries keep substring matching (sessionId).xstate-persistence:receipts::STATUS_NO_OP(HTTP 200, Python’s renamedSTATUS_NOT_MODIFIED_OK) names the clean no-op status;ReceiptErrorKind::UnresolvedTarget(Python’s newUnresolvedTargetError, a subclass ofActorSpawningError) maps to HTTP 500.xstate-persistence:ReceiptJson::to_value_with/to_json_with(include_messages)write error messages as""for receipts shown to untrusted clients;to_value/to_jsonkeep the message.xstate-persistence:FileStore::delete/forgettake the key’s lock, so a concurrent save can no longer resurrect a deleted record;list_keysonly returns keysloadwould accept (and fillslimitwith them); a save fsyncs the directory after the rename on Unix.xstate-persistence:MemoryStore::forgetno longer discards a lock that is still held or awaited, which let a second holder in.xstate-persistence:SqliteStoreOptions::busy_timeoutabovei32::MAXms is refused at open;JournalModeoffers onlyWalandDelete(the other modes are removed).xstate-persistence:PersistedOptions::expected_machine_hashis checked against the machine itself on a new key and against the stored snapshot on a restore, where it also forces the drift check on whenverify_hashisfalse.xstate-persistence:DeadlineExt::remaining_msclamps to0..=MAX_REMAINING_MS(1e15) instead of returning infinity for a huge finitedue_at_wall.xstate-persistence:DueTimerScannergainedvalidate()(negative or non-finiteskew_tolerance_s,limit == 0are refused instead of clamped; called by every scan),last_result()(Nonebefore the first scan) andrun_until_checked, which rejects a zero interval;run_untillogs and returns instead of spinning.xstate-core:start()landing directly in a top-level final state now runs the exit actions of the final state and the root, as XState v5 does (e_root, e_done, x_done, x_root).xstate-core:alwaystransitions are selected in the order their atomic states were entered, as XState’sselectEventlessTransitionswalkssnapshot._nodes(states a microstep keeps stay in place, the ones it enters are appended in document order; a microstep that re-enters exactly what it exited without recording history keeps the order; a restored snapshot starts in document order). The engine visited them in document order, so after one region moved, the other region’salwaysguards ran second and a conflict between two regions’alwaystransitions could go the other way.ActiveStatekeeps a per-state entry stamp (entered_at); the eventless pass sorts its (inline) leaf list by it.xstate-core: an eventless microstep that leaves the snapshot unchanged – same states, no history recorded, noassign, no actor started or stopped, no in-place context change by a user action – no longer re-selects eventless transitions (XState’smacrostep). A self-targetingalwayswithreenter: true, or on a compound state, ran again untilmaxIterationsfailed the machine; it now runs once per macrostep (and once more per internal event).ActiveState::mutationscounts snapshot changes instead of every exit and entry.xstate-core: a parallel state with ahistorychild now completes (done.state.*,onDone) when its regions are final:isInFinalStateskipped no child, and a history pseudo-state is never final.xstate-eda:replay_dead_letterrefuses a dead letter whose stored envelopedataholds the redaction sentinel"***"as a leaf (it would have replayed masked values and resolved the record), withEdaError::ReplayRefusedunlessReplayOptions::forceis set (Python 0.12.0).xstate-eda: aStoreErrorthat is an outage (Unavailable,Conflict,LockTimeout,SnapshotTooLarge,InvalidKey) returned by anEnvelopeHandlerconverts to the newHandlerError::Requeue, which the dispatcher requeues without counting an attempt instead of dead-lettering the envelope as poison aftermax_attempts;Corruptand engine errors still count (Python 0.12.0).xstate-eda:SqliteOutboxStorelogs and skips a row whose envelope no longer decodes instead of failing the whole read, andclaimparks it under the newPOISON_OWNERlease so no relay selects it again; later rows keep relaying (Python 0.12.0).xstate-eda:OutboxPluginlogs a warning when ameta.publishdatafield is missing from the context (the field is still published asnull).
Changed
-
xstate-core: event dispatch is 2 to 3 times faster and allocation-free (send_flat446 to 192 ns,send_nested_3642 to 212 ns,send_parallel_2543 to 213 ns, Python-parity S1 to S4 at 74x, 39x, 80x and 92x the Python library); machine construction is about twice as fast (S5 13.6 to 6.9 µs,build_50_states203 to 129 µs). Semantics are unchanged (all conformance cases, goldens and tests pass). What changed inside the engine:- per-machine precomputed transition domains and entry sets (machines without history states), exit sets read from the domain’s subtree range, a configuration bitset stored inline for up to 128 states, transition selection that walks up from the single active leaf of a machine without parallel states and skips subtrees that handle none of the event’s descriptors;
SyncInterpreter::sendprocesses the event in place when nothing is queued ahead of it, reuses its effect buffer forstart, reads the clock only when a timer is armed, and builds one report persend;settleis skipped when nothing is eventless or raised;done.stateevents are no longer built when the same entry finishes the machine (they were discarded unseen), andaftertimer event types are interned at compile time;- parsing reads configuration objects by scanning their few keys instead of hashing, builds
state ids in one buffer, and
Machine::from_value/from_jsonkeep the source document as compact JSON text instead of a deep copy of the value (Machine::to_jsonparses it back: the document is equal to the input, at the cost of a parse when it is asked for); MachineLogic::validatereturns at once for a machine that names no action, guard, service or delay, and the logic registry hashes withfoldhash.
-
xstate-core:SyncInterpreter::newshares one process-wideRealClockinstead of creating one per interpreter (only differences of its readings are used, for timer due times). -
xstate-core: no longer depends onfixedbitset. -
xstate-core: second performance pass. Interleaved with the first pass’s tree on the same machine (criterion medians, best of three each): S1 -20 %, S2 -24 %, S6 -48 %, S7 -24 %, S5 -11 %,send_nested_3/send_parallel_2-18 % / -20 %,build_phase_compile-32 %. Semantics are unchanged (53 conformance cases, goldens, workspace tests and the Python bindings’ tests pass). What changed:- API:
Receipt::actionsandStepReport::actionsare nowActionList(SmallVec<[Arc<str>; 2]>, exported from the crate root; derefs to[Arc<str>]) instead ofVec<Arc<str>>, so a send whose step runs one or two user actions allocates nothing.Receipt::action_names()still returnsVec<String>. No caller in the workspace needed a change. Effect::CancelTimersis emitted only for exited states that declareafterentries (the only states that own timers), so a step that arms no timer emits no effect and an interpreter that never arms one never allocates its effect buffer.SyncInterpreter::from_snapshotnow refuses (Error::Snapshot) a deadline whose state declares noaftertimer; such a timer could never have been armed by the engine.- The logic registry stores its names in insertion order and finds them by comparing names
(a hash index is built above 16 entries): creating an empty
MachineLogicno longer seeds four hash maps and a guard or action lookup no longer hashes its name.MachineLogic::action_nameslists the actions in registration order. - The exit set of a single transition is appended in document order without searching, the entry set of a planned transition is borrowed from the machine instead of copied, and the set of completed parallel states is only created when a final state is entered.
startbuilds the runtime state in the interpreter’s slot instead of moving it there, and skips settling when the machine has no eventless transition and nothing was raised.- Compilation finds event keys and child states by scanning for machines with up to 8 event types / 32 states (hash indexes above) and keeps the flattened state list inline: three fewer allocations for the S3 machine (61 to 58).
- API:
-
xstate-core: third performance pass. Interleaved with the second pass’s tree on the same machine (criterion medians, best of seven runs each): S1 -37 %, S2 -18 %, S3 -31 %, S4 -34 %, S5 -43 %, S6 -19 %, S7 -30 %; S1, S3 and S4 now run at more than 100x the Python library. Semantics are unchanged (53 conformance cases, goldens, workspace tests and the Python bindings’ tests pass). No public API changed. What changed:- Plain microsteps. Per transition the precomputed plans now also record whether taking
it alone is plain: nothing it can exit has exit actions,
aftertimers or invocations, and nothing it enters has entry actions,aftertimers or invocations or is final (and the machine has no history states). A single enabled plain transition skips the general microstep (conflict resolution, exit / entry set construction, history recording, completion checks, per-state effect scans): it clears the exit range’s configuration bits, runs the transition’s actions and sets the precomputed entry set, with the same report, entry stamps and snapshot-change count. A randomized differential test (engine::fast_path_tests,proptest: random machines of up to three levels with parallel regions, finals, history, guards, actions, entry / exit actions,reenterand guardedalways, driven by random event sequences) runs every case with the fast path on and off and compares receipts, runtime state and action traces. - Direct compilation.
Machine::from_value/from_jsoncompile plain documents straight from the JSON value into the arena (machine::direct): the sameCompilerresolves targets, interns events and builds the tables, but no ownedMachineConfigis built. Plain means plain states (id,initial,states,type,on,always,after,entry,exit,description), transitions as target strings or objects withtarget,guardandactionsby name,reenteranddescription, user actions only, and a root with at mostcontextandversionbesides; everything else (built-ins, action / guard objects,invoke,onDone, history, policies,x-keys, unknown keys, and every malformed document) goes through the parser as before, which produces every warning and error. A test builds every machine document of the repository (conformance cases, CLI fixtures, agent charts), 79 edge cases and 400 random machines both ways and compares the compiled machines field by field (and the errors). The S3 machine now takes 20 allocations instead of 58.MachineConfigandMachine::from_configare unchanged. - The initial entry set is computed once per machine (nothing is recorded in history before
a machine starts); interpreters built with
SyncInterpreter::newborrow the process-wide real clock instead of holding anArcto it; a step that requests no effect skips the effect pass; the receipt of a machine without parallel states reads its single leaf off the configuration; event types and implementation names are compared inline instead of throughmemcmp;send(&str)builds short event types inline.
- Plain microsteps. Per transition the precomputed plans now also record whether taking
it alone is plain: nothing it can exit has exit actions,
-
Conformance: the Python engine converged on XState v5 for the eight recorded divergences (Python branch
xstate-v5-engine-parity);conformance/divergences.generated.mdis empty and cases 020, 021, 027–032 now carry identical Python and XState expectations. -
Conformance: eight new cases (
038–045) pin the remaining engine differences the Python engine has now converged on – conflict preemption and exit/actions/entry grouping in one microstep,done.state.*on the internal queue, guard evaluation order,assignordering, a compound self-target withoutreenter, and a JSONassignwithout theassignmentwrapper. The case format gained asend_batchstep (events queued together before processing) andlogic.record_guards, which adds the guard evaluations to every observation (guards); the XState oracle, the Python generator, the schema andxstate-conformance(Step::SendBatch,Logic::record_guards,Observation::guards,runner::stub_logic_recording) support both. The Rust engine already matched XState on all eight. The Mermaid / PlantUML goldens now include031_multi_target_parallel_entry, which the Python library renders since it accepts multi-target transitions. -
Conformance: five new cases (
046–050) pin the last known engine differences, now fixed in both engines:alwaysselection in entry order (046_always_order_follows_entry_order,050_always_order_after_reentry), history recorded only when its owner is exited (047_history_records_only_on_owner_exit; the Rust engine already did this), a self-targetingalwaysruns once (048_always_self_target) and which ancestors receivedone.state.*(049_done_state_propagation: the compound parent, then every completed parallel ancestor; a"*"handler sees the ones nobody handles).
0.1.0 - 2026-11-02
First release: a Rust port of the Python library
xstate-statemachine 0.12.0 with an XState v5
compatible engine, Python-compatible snapshots and store layouts, and the feature set described
below. Developed in phases 0 to 7; the headings follow them.
Added
Phase 0: design and foundations
- Feasibility report and five ADRs: spec-faithful engine semantics with XState v5 as the oracle (ADR-0001), a two-tier dynamic / typed context and event model (ADR-0002), byte-compatible snapshots and wire formats with the Python library (ADR-0003), a sans-I/O step engine shared by every interpreter (ADR-0004) and the workspace and quality gates (ADR-0005).
- Cargo workspace (edition 2024, MSRV 1.88,
unsafe_code = "forbid", clippy pedantic,missing_docs), CI (rustfmt, clippy, tests on three operating systems, MSRV, rustdoc, cargo-deny, cargo-audit, coverage, conformance fixture freshness) and the benchmark budget layout. - Conformance suite: JSON fixtures run through two oracles, the real XState 5.33 engine under Node
and the Python library, recording both observations plus Python’s
machine_hashand snapshot vectors. Divergences between the oracles are documented and generated.
Phase 1: the engine (xstate-core, xstate-tokio)
- Lenient XState v5 JSON parser (shorthands,
cond/internalv4 aliases, forbidden transitions, inferredinitial, unknown-key warnings or errors understrictConfig, editor keystypes/schemasaccepted), machine compiler with resolved targets, composite guards and every built-in action (assign,raise,sendTo,sendParent,forwardTo,escalate,log,cancel,stopChild,spawnChild,emit,choose). - Sans-I/O step engine transcribed from XState v5 (conflict preemption, exit and entry sets,
history,
onDonepropagation, eventless settling withmaxIterations, action and guard error policies (actionErrorPolicydefaults tofail, as in XState),onUnhandledignore / defer / error) and the synchronous interpreter with a timer heap andClock/SimulatedClock. - Snapshots in the Python layout v4 (same fields, order and values; compact JSON where Python’s
get_snapshot()indents, pinned by a text comparison against Python’s output) and the machine structure hash, byte-for-byte; restore with hash verification and timer restart modes (parked / restart / resume / fire-due;RestoreOptions::default()resumes, where Python’s barefrom_snapshotparks). xstate-tokio:spawnand the cloneableInterpreterhandle, bounded mailbox with Block / DropNewest / Reject overflow,aftertimers ontokio::time, async services, child machine actors,sendTo/sendParentrouting,systemIdregistry, watch-basedview().- Builder API, pure API (
initial_transition,transition), exporters (to_json, Mermaid and PlantUML, byte-identical to Python on the fixture machines), graph utilities (reachable_states,shortest_paths,simple_paths, coverage targets,stub_logic) and theObservertrait. - Engine benchmarks and the first performance budgets.
Phase 2: persistence (xstate-persistence)
StateStorecontract with memory, file (.xsm.jsonrecords, atomic rename, advisory locks) and SQLite stores sharing the Python layouts; cross-language tests in both directions.- Optimistic and pessimistic locking, the
persistedtransaction wrapper with retries and after-commit callbacks, durableafterdeadlines andDueTimerScanner, the idempotency inbox and receipts, the transition / audit log, snapshot migrations andadopt.
Phase 3: observability, inspector and CLI (xstate-observability, xstate-inspect, xsm)
MetricsObserverover themetricsfacade with the PythonPrometheusPluginmetric set and label hygiene (declared events only, capped cardinality).- Stately Inspector protocol (
@statelyai/inspect0.7.2):InspectorObserverwith deny-by-default context and payload allow-lists and key redaction, memory and JSONL sinks, paced replay; recordings are byte-identical to the PythonInspectorPlugin. xsmCLI:validate,inspect,diagram,docs,simulate(scripts, guard overrides, virtual clock,--record),paths,snapshots,info, with--jsonoutput and exit codes 0 / 1 / 2.
Phase 4: event-driven architecture and patterns (xstate-eda, xstate-patterns)
- CloudEvents 1.0
Envelope(byte-identical JSON, UUIDv7 ids), theBrokerAdapter/SyncBrokerAdaptercontract with fake brokers,InboundDispatcher(per-subject order, inbox dedup, bounded retries, poison messages to dead letters, replay), the transactional outbox with memory and SQLite stores and a relay with claim leases, dead-letter stores and plugin, AsyncAPI 3.0 generation. RetryPolicy,CircuitBreaker,SagaBuilderandChoreographyRouter, emitting the same chart JSON as the Python builders.
Phase 5: typed DSL, HTTP and Redis (xstate-macros, xstate-axum)
#[derive(TypedEvent)],#[derive(TypedState)]and the compile-time checkedstatechart!DSL, withtrybuilddiagnostics tests.xstate-axum:StatechartRegistry, REST and per-event routes, SSE and WebSocket streaming, RFC 9457 problem details, idempotency keys, authorizers, probes, graceful drain and the token-gated Stately Inspector WebSocket endpoint.- Redis backends:
RedisStore,RedisInbox,RedisLog(featureredisofxstate-persistence) andRedisStreamsBroker(featureredis-streamsofxstate-eda) with the Python key layout and Lua compare-and-set scripts.
Phase 6: PostgreSQL, more brokers, live inspector, agents (xstate-agents)
- PostgreSQL backends over sqlx (feature
postgres):PostgresStore,PostgresInbox,PostgresLogwith the Python SQLAlchemy schema, lease-row locks and compare-and-set saves. NatsBroker(JetStream, durable pull consumers,Nats-Msg-Iddedup) andRabbitMqBroker(durable queues, publisher confirms), async and blocking.SqsBroker(featuresqsofxstate-eda, exposed by the facade assqs): the Amazon SQS adapter (queue per topic, FIFO groups and deduplication ids, long polling, visibility-timeout redelivery), wire-compatible with the Python library’scontrib.brokers.sqs. Tested against ElasticMQ throughXSM_SQS_ENDPOINT.SseSink, a standard-library-only hardened HTTP/SSE server for the Stately Inspector page, andxsm inspect --live/xsm replay.xstate-agents: the tool-loop, supervisor, pipeline and debate charts,AgentLogicwith allow-lists, schema checks and approvals, budgets as guards, tool registry, runner with snapshot resume, trace observer, structured output and multi-agent helpers; six Python golden scenarios match exactly.
Phase 7: release preparation (xstate-statemachine)
- The
xstate-statemachinefacade crate: one dependency re-exporting every library crate behind feature flags that mirror the Python extras (tokio,persistence,sqlite,redis,postgres,eda,redis-streams,nats,rabbitmq,sqs,patterns,observability,inspect,macros,axum,agents,testing,full) and aprelude. - README for every crate, a refreshed root README with a feature-parity matrix, and the guide in
docs/guide/: getting started, the Stately editor, persistence, event-driven architecture, migrating from Python and the feature-flag reference. - CI: an
integrationjob against Redis, PostgreSQL, NATS (JetStream), RabbitMQ and ElasticMQ, a benchmark build job, and cargo-deny over the whole workspace; arelease.ymlworkflow with a tag check, the gates, a dry-run publish in dependency order and a gated real publish;scripts/publish-order.txtandscripts/publish.sh. - Workspace dependencies on
xstate-persistenceandxstate-edaare declared withdefault-features = false(cargo 1.88 rejects a member that disables defaults the workspace entry enables);xsmenablessqliteitself. The crates keepsqliteas their own default. - The CLI package is
xstate-statemachine-clibecause the crate namexsmis already taken on crates.io; the installed binary is stillxsm. xstate-py: Python bindings (PyO3) backed by the Rust engine. Work in progress; not published to crates.io (publish = false); CI builds it with maturin and runs its pytest suite.
Known limitations
- MSRV is 1.88. The
postgresfeature (andfull, which includes it) needs Rust 1.94 (sqlx0.9); theMSRVCI job checks the workspace and every other feature on 1.88 and skipspostgres. - The outbox is at-least-once and has memory and SQLite stores only (no PostgreSQL outbox).
Not yet supported
- Kafka broker adapter.
xsmcommandscoverage,asyncapi,dlq,generate-template,list-templates,new,plugins,updateandsetupof the Python CLI.- OpenTelemetry and structlog / Sentry observers (
xstate-observabilitycovers the Prometheus metric set through themetricsfacade).
Fixed
serde_jsonfloat_roundtripis enabled workspace-wide so wall-clock deadlines survive a JSON round trip bit for bit.
Security
- Dependency tree cleaned for
cargo deny(advisories, licenses, bans, sources): the AWS SDK now uses itsdefault-https-client(hyper 1 and rustls 0.23) instead of the legacyrustlsfeature, which removesh20.3,rustls0.21 andrustls-webpki0.101 and their advisories;async-natsis updated to 0.50 (rustls-webpki0.103). One informational advisory (RUSTSEC-2025-0134,rustls-pemfileunmaintained, reached through lapin 3) is ignored with the reason recorded indeny.toml.