Changelog
Release history and what changed in each version.
Changelog
All notable changes to XState-StateMachine for Python are documented here.
For the full changelog with commit history, see CHANGELOG.md on GitHub.
[Unreleased] — targeting 0.8.1
Naming, and the site.
Fixed
- Loop-side
RAISErefusals fromsend_threadsafe()are observable (#157, reopened). The call-siteqsize()check is optimistic; under load — the only time backpressure matters — a concurrent producer is refused on the loop, and that refusal landed only on the future the fire-and-forget pattern never reads: correct load shedding with a hidden shed rate. The future still carries the error; the interpreter now also logs a WARNING and fireson_event_dropped(reason="queue_full")for each such refusal. Same-threadsend()underRAISEis unchanged — the exception reaches the caller and is the signal. - Round-6 re-verification findings (#166–#175; reopened #122). Every one
reproduced against
mainwith an independent probe before the fix and pinned intests/test_round6_findings.py, both engines wherever parity is the point. Three of the four candidate blockers were “fixed on the engine the issue was filed against”; the fixes below are on the async engine and each test runs the sync engine alongside it.- Every self-generated cycle is bounded on
Interpreter. Analwaysinto a child whose plain service finished inside the settle pass hungsend(wait=True)for ever (#166): the settle budget was a local counter per call, restarting at 0 on every completion-driven re-entry. It is now per macrostep on the instance, reset only when an external event begins its step — the sync engine’s #103/#151 rule — and a trip is observable (last_errorisRunawayChainError). A completion the machine produced while processing (a rollback that re-armed an invoke, #167; an invoke ping-pongver -> arm -> ver, #168) is charged to the chain budget, and only the first completion that arrives at the trip is spared (#120). A drop that leaves nothing self-generated pending ends the chain, so a service that finishes later after an idle trip is still delivered. The invoke cycle now trips at the same lap count on both engines. get_persisted_snapshot()from inside an entry/exit action is refused on both engines (#169). The guard wasin flight AND illegal; inside an entry action the new leaf is already active (legal) while the context that entry is still writing is half-applied, so a torn “filled withfilled_qty=0” blob persisted and restored cleanly. At the root, in flight alone now refuses; legality remains the test for the bounded wait on a child caught mid-step by its parent.Receipt.deniedisFalsefor a guard that crashed underguardErrorPolicy: "raise"(#170);errorcarries the exception, so(denied, error is None)discriminates all three cases. Documented, with the note that a denied event underonUnhandled: "defer"enters the defer buffer.await Interpreter.start()returns with the initial configuration’s invoked children registered (#171), sosendTo("kid")on the first event resolves as it does afterSyncInterpreter.start(). A plain service the initial state invoked still runs whilestart()returns (#149), but its completion is awaited before the first inbox event is read, sostart(); send("CANCEL")orders identically on both engines (#116).send_threadsafe(internal=True)in-flight counter balances on every terminal outcome (#172) — delivered, refused, cancelled, loop stopped before the coroutine ran — via a done-callback on the returned future. A leaked count gated the chain-budget reset for the rest of the machine’s life.tick()on aRealClockreal-delay ladder (#122, reopened): closed as working as designed.tick()drains what is due at the current reading; no synchronous call can make wall time pass, so a 50 ms ladder needs onetick()per rung (or aSimulatedClock). The repro encoded the async engine’s wall-clock wait as a sync expectation.
- Every self-generated cycle is bounded on
Added
Interpreter(service_pool_size=N)andDEFAULT_SERVICE_POOL_SIZE(#173). The executor plain-defservices run on was a hard-coded pool of 4; the fifth concurrent service waited in a wave, and because the entering macrostep awaits the result each wave blocked a macrostep — nine 0.2 s services took 5 s, worse than serial. The size is now public and its interaction with macrostep blocking is documented.
Changed
- Production Characteristics documents that a plain-
defservice blocks its own machine’saftertimers for its whole duration (#174) — anafter: 100armed alongside a 500 ms plain service fires at ~500 ms; make the service a coroutine if a timer must interrupt it — and that themaxIterationssettle budget bounds microsteps, not wall-clock lateness.
Fixed
- Round-5 re-verification findings (#142–#162; reopened #118, #122,
#125, #133, #134). Twenty-six issues, every one reproduced against
mainwith an independent probe before the fix and pinned intests/test_round5_findings.py(52 tests, both engines wherever parity is the point).- Configuration legality, both directions. The mid-step snapshot
guard tested “some atomic node is active”; in a
parallelmachine one region mid-transition left the other’s leaf to satisfy it and the snapshot recorded a torn region (#142). Legality is now exactly one active leaf per region (_configuration_is_legal), used on the write side and mirrored on the read side: arunningsnapshot whoseconfigurationlost its leaves restored as a live, permanently inert machine (#143) and is nowSnapshotCorruptError. - Hostile snapshot fields are typed (#146):
version,status,history,actors,system,deferredand a non-strpayload all raised bareTypeError/ValueError/AttributeError; a pending event with a non-strtypewalked in through the restore door (#158). Every top-level keyfrom_snapshotreads is now shape-checked andrestore_eventre-checks per record. actionErrorPolicy: "fail"stops the machine as documented (#145):statusis"stopped", the configuration is cleared, children and timers are torn down, and theTransitionFailedErroris retained on.error. Before, it parked in"error"still reporting the pre-transition leaf — a bricked machine that persisted as resumable. A child stopped this way fails its parent’sinvoke(onError) on both engines. Read side: an"error"snapshot with no recorded error is refused.strict_targets=Falseno longer reopens the root-target hole (#147): the #108 rejection was emitted from the unresolvable-targets branch that the flag downgrades to a warning. It is now a non-downgradableRootTargetErroron every flag setting.- Two livelocks / budget faults on the sync engine. A nested
invokewhoseonDonere-enters the common ancestor is a conservative cycle (dequeue one, enqueue one) that reset the chain budget every lap and hungstart()for ever regardless ofmaxIterations(#144); a chain now ends only when nothing self-generated remains queued. Thealways-settle budget was reset per drain, so two independent events in onesend_events()batch shared one allowance and the second tripped wheresend(A); send(B)did not (#151); it is per macrostep. - Async run-loop death is published from the task (#148): a cancel
landing before the loop’s first scheduling turn never entered the
coroutine body, so #114’s handler never ran —
status="running",is_running=False,send(wait=True)hung. A done-callback now fires for every way the task ends (_dieis idempotent). - Plain-
defservices run off the loop (#149): #116 made them run inline so their completion lands at the same point as on the sync engine, at the price of blocking the event loop for the service’s whole duration — every timer, actor and inbound send stalled. They now run onInterpreter(service_executor=...)(default: a small ownedThreadPoolExecutor) and the entering macrostep awaits the result, so #116’s ordering holds while the loop keeps turning. send_threadsafeis classified and bounded on the calling thread. An action that handed its own re-trigger to a worker thread was never charged tomaxIterations(#150): the self-send decision is made on the caller’s thread (context-inheriting threads/executors are recognised; a plainthreading.Threadshould passinternal=True), in-flight self-sends keep the chain alive, and the trip is observable. UnderOverflowPolicy.RAISEa full inbox raisesQueueOverflowErrorat thesend_threadsafe()call site instead of on a future the fire-and-forget pattern never reads (#157).guardErrorPolicy: "raise"cancels only its own candidate (#152): the exception used to abort the whole selection pass, so an unguarded fallback on aninvoke.onDonewas never taken and the completion was lost. The fallback is now taken; a caller-driven event still delivers the exception to the syncsend()caller / async receipt, and an engine-driven one records it onlast_transition_ok/last_error.- Guard-denied is distinguishable from undeclared (#153):
on_unhandled_eventreports"guard_denied"andReceipt.deniedisTruewhen a handler was declared but every guard refused. - Sync engine parity for three round-4 fixes (reopened): a deferred
event’s replay is its own macrostep on
SyncInterpretertoo — the caller’sReceiptis final before any replay runs (#125);on_resolve_errorfires from the shared algorithm, on both engines (#134);forwardTosharessendTo’s unresolved-target reporting (on_event_dropped(reason="unresolved_target")+ soft step error) through one helper (#133). - Sync restore attaches the
SimulatedClock(#154): both restore branches ofstart()returned beforeclock._attach(tick), sorestart_timers=Truere-armed deadlines nothing would ever drain. - A user action named
spawn_*is the user’s (#155): the built-in spawn prefix was resolved beforelogic.actions, the only built-in that claimed a name out of the user’s namespace; discovery and the runtime now both prefer an implemented action. escalatereachesonErrorwithout an explicitinvoke.id(#156): the child records the invoke id its parent knows it by (_invoked_as) instead of parsing it back out of a runtime actor id whose first segment is the service key for anonymous invokes.- Error hooks (#159): new
on_invalid_eventandon_snapshot_errorfire beforeInvalidEventError/SnapshotMidStepError/SnapshotSerializationErrorpropagate. - Redaction (#160):
get_snapshot()’s DEBUG log is redacted (it wrote the whole context verbatim,LoggingInspectoror not);DEFAULT_REDACT_KEYScovers financial, session and personal identifiers (iban,pan,cvc,bearer,cookie,session,signature,otp,pin,mnemonic,seed_phrase,dob,email,phone,passport, …);LoggingInspectorredacts service results andDoneEvent/ErrorEventdata. - Dict-event validation is explicit (#161): the mapping form
requires a non-empty
strtypeandstrkeys (non-strkeys raiseInvalidEventError); payload values are the caller’s — documented onsend()for both engines. - v1 pending events are user events (#162): re-deriving provenance
from the name laundered a user’s
after.hoursinto an engine event exempt fromonUnhandled/strict. Only the init sentinel keeps system provenance. See the migration note in Snapshots. - Telemetry honesty (#118): absent
AfterEvent.scheduled_for/fired_atrestore asNone, never0.0;lateness_msisNonewhen unknown.tick()contract documented (#122): it drains what is due, does not advance time; a real-delay ladder needs onetick()per rung or aSimulatedClock.
- Configuration legality, both directions. The mid-step snapshot
guard tested “some atomic node is active”; in a
- Round-4 re-verification findings (#102–#138; reopened #91, #99).
Thirty-nine issues, every one reproduced against
mainwith an independent probe before the fix and pinned intests/test_round4_findings.py. Two blockers first:- Mid-macrostep snapshots are refused (#102): between a transition’s
exit set and entry set the configuration has no leaf; a snapshot taken
there persisted
state_ids: []and restored as a permanently inert machine reportingrunning.get_persisted_snapshot()now raisesSnapshotMidStepErrorin that window. SyncInterpreter.start()terminates (#103): a cross-regionalwaysinto an invoking state re-armed the invoke on every settling pass and the microstep budget restarted at 0 each time, so it tripped forever. The budget is now per macrostep. A settle trip is also observable and leaves a legal configuration (#112).- Engine parity. A plain-sync
invokecompletes at the same point on both engines (#116 — the identical(GO, CANCEL)×10script gaveok=10on sync andcancel=10on async; the async engine now runs a non-coroutine service inline, and the sync engine queues an in-step completion ahead of the inbox, sosend_events([GO, X])andsend(GO); send(X)agree too); an unhandled invoked-child failure fails the parent on both (#99);send()to a stopped machine, the initon_transitionrecord, andstop()’s abandoned events fire the same hooks on both (#123, #124, #129); the async trip spares engine completions like the sync one (#120). - Async
send()underOverflowPolicy.BLOCKenqueues eagerly when the inbox has room (#104) — a fire-and-forget send was silently lost even on an empty inbox. An external producer sending during an in-flight step is no longer charged tomaxIterations(#105): the self-send gate is now “issued from one of this interpreter’s actions”, tracked per task, not “the loop is busy”. - Persistence. The priority (fired-timer) lane is persisted (#107);
aftertimers can be re-armed on restore withrestart_timers=Trueandhas_dormant_timersreports when they are not (#128);from_snapshot(clock=)(#117); malformed snapshots raiseSnapshotCorruptError(#110); non-JSON pending data raisesSnapshotSerializationErrorinstead of being stringified (#131);AfterEventlateness telemetry round-trips (#118);statusafter a restore is documented as not-a-liveness-signal (#135). - Provenance.
send(engine_event, wait=True)no longer strips the engine marker (#111); the marker survivesdeepcopy/pickle(#138);is_system_event,system_event,DoneEvent,AfterEvent,ENGINE_EVENT_SHAPESare exported and documented (#137);Receipt.deferredbookkeeping is per-step and by reference, so it can neither grow nor mislabel an unrelated later event (#106); a deferred event’s replay is its own macrostep and no longer folds into the triggering event’sReceipt(#125). - Actors.
done.invokecarries the child’s declaredoutput, not its private context (#109);escalatefrom an invoked child reaches the parent’sonError(#130); asendTowith no live target fireson_event_dropped(reason="unresolved_target")and marks the step (#133). - Validation. A transition targeting the machine root is rejected at
build (#108); a bare
stateInname that is ambiguous in the machine is rejected at first use (#132); a self-referential config dict raisesInvalidConfigErrorinstead ofRecursionError(#136); a non-streventtyperaisesInvalidEventError(also aTypeError) instead of escaping the hierarchy (#113); two config names that normalise equal and resolve to one callable warn (#91); a duck-typed logic object is copied like aMachineLogic(#121). - Plugins. A hook raising
asyncio.CancelledErroris contained like any other failure, and an externally cancelled run loop flipsstatustoerrorand fails pending receipts instead of leaving a dead machine reportingrunning(#114); anasync defhook is reported via the newon_plugin_error/last_plugin_errorinstead of silently never running (#127);on_resolve_error(#134);LoggingInspectorredacts sensitive keys by default (#126). - Timers.
SyncInterpreter.tick()drains chained due deadlines in one call (#122);SimulatedClockdetaches an interpreter’s settle hook on teardown (#115). - Ride-alongs found while landing #102 / #116. A non-blocking
spawnChildonSyncInterpreternow starts the child on the spawning thread (its pump thread only ticks it), so a snapshot,sendToorstop_childissued right after the spawn sees a fully entered child and its grandchildren — previously a load-dependent race. The #102 mid-step refusal applies to the root ofget_persisted_snapshot()only; a child actor caught mid-step is waited for (bounded) instead of failing the parent’s snapshot. A plaindefservice that returns an awaitable, or aunittest.mock.AsyncMock, that fails now reachesonErroron Python 3.9–3.11 too.
- Mid-macrostep snapshots are refused (#102): between a transition’s
exit set and entry set the configuration has no leaf; a snapshot taken
there persisted
- Round-3 re-verification findings (#84–#99; reopened #31, #77, #79).
Every item was reproduced against
mainbefore the fix and pinned intests/test_round3_findings.py.Receipt.deferred(#84): an event held byonUnhandled: "defer"resolvedchanged=False, error=None— indistinguishable from a correct no-op. The receipt now saysdeferred=True.- Provenance is not forgeable (#85):
Event(system=True)let user code mint engine-status events that bypassedstrict,onUnhandledand"*". The public constructor has no such parameter;Event.systemis a read-only property backed by an engine-private identity sentinel that onlysystem_event()can set. - Provenance and engine events survive a snapshot (#86, #87): pending
DoneEvent/ErrorEventwere silently dropped byget_persisted_snapshot(), and a restored engineEventbecame user traffic that failed anonUnhandled: "error"machine. Snapshot layout v2 persists akindper record and round-trips every event class; v1 restores unchanged. - Runaway-chain trip is observable (#77 criterion 6): the triggering
receipt carries
RunawayChainError,last_transition_okisFalse,last_erroris set, andon_event_dropped(reason="chain_budget")fires per discarded event — on both engines. trippedis per chain (#88): one runaway no longer starves unrelated events queued behind it in the samesend_events()batch.- Completions are never discarded (#94): a
done.invoke/error.platformarriving during or after a trip is delivered, so a trip can no longer strand the machine in the invoking state. It is still counted, so a rollback→re-arm→done cycle remains bounded. - Async action-side
send()is budgeted (#90): an action callingawait interp.send(...)on its own interpreter spun unbounded; it now routes to the internal queue and counts against the chain likeraise. **kwargsis not consent (#89): a legacy clock wrapper forwarding**kwargswas fedsync=; only an explicitly named parameter opts in.create_machine()no longer mutates the caller’sMachineLogic(#92): aliases are resolved into a machine-owned copy of each registry, so a second machine from the same logic still trips the ambiguity guard and an earlier machine is never retroactively rebound.- Shadowed near-duplicates warn (#91): an exact key still wins by
design, but if a different callable is also registered under a
spelling that normalises to it, a
UserWarningnames both. logic_modules/logic_providersapply the ambiguity rule (#93): two different callables whose names normalise equal, for a name the machine requires, areInvalidConfigErrorinstead of iteration-order roulette. The legacy forward snake→camel alias that masked this is gone.- Library no longer reads
ErrorEvent.data(#95), so-W error::DeprecationWarningCI passes. _resolve_event_specalways yields a dict payload (#96): anErrorEventre-sent throughsendTo/forwardTocarried the exception as the payload; it is now{"error": exc, "src": id}.escalatemints anErrorEvent(#97) — the one failure path that still delivered a plainEvent.- Strict mode exempts by provenance only (#98): forged engine-shaped
user events (
done.invoke.NEVER,after.party,xstate.whatever,___xstate_forged) are rejected like any undeclared name. SyncInterpreterdeliversonErrorfor a failed invoked child machine (#99), and fails the parent when no handler is declared — parity with the async engine and with failing callable services.- Engines cut a deep chain at the same link (#77 ride-along): the
sync budget counted raises seeded by
start()’s initial entry; the async one did not, so a 1 001-deep chain landed ons1000vss1001. Pre-drain internals now have user-event standing on both engines. _SIBLING_FALLBACKS_WARNEDis bounded (#31 ride-along) to 1 024 pairs; a long-lived process no longer accumulates entries forever.- Runtime parity for unresolvable targets under
strict_targets=False(#31): both engines now expose the same surface —StateNotFoundErroron the receipt,last_transition_ok=False,last_errorset, machine stillrunning; the sync engine additionally raises from a fire-and-forgetsend()as before.
send(event, wait=True)no longer hangs when oneEventinstance is in flight twice (#75, #39). Receipts were keyed onid(event), so two concurrent sends of the same pre-builtEventcollided and the first awaiter never resolved — no error, no timeout, andstop()could not reach it. The queued envelope now gets its own identity; the caller’s object is never mutated and reuse as a template is fine.SyncInterpreterafterdeadlines are reachable bytick()even when the interpreter is constructed inside a running asyncio loop (#76, #50).RealClock.set_timeoutchose its lane by whether a loop happened to be running on the calling thread; a sync machine built inside one parked its timers onloop.call_later, where its own pump could not see them. The lane now follows the owning engine (sync=onset_timeout); third-party clocks written against the 0.8.0Clockprotocol still work — the engine inspectsset_timeout’s signature once at construction and calls it exactly once, so a clock’s own errors surface unchanged.SyncInterpreterno longer discards a batch of more thanmaxIterationsexternal events (#77). The runaway guard counted every dequeued event andclear()ed the inbox on overflow, sosend_events(["T"] * 1501)processed 1000 and silently dropped 501. It now budgets only self-generated work — events that arrive while the drain is running (araise, an action callingsend()on its own interpreter, adone.invokefrom a sync service, a due timer). Every event that was in the inbox when the drain began, or is replayed from the defer buffer, is processed in full regardless of count. The budget is per chain, matching the async engine: it resets whenever a macrostep generates nothing, so 3 000 independent one-deepraises in one batch are all delivered, while a self-feeding loop is still broken and only the generated tail is discarded — never events the caller was told were accepted. The 0.8.0 note claiming the two engines already agreed was wrong; they do now, and an engine-parity test pins it.- System-event exemption is decided by provenance, not by name (#79).
The
"*"/"prefix.*"wildcard matcher,onUnhandledandstrictmode used to exempt any event whose type began withdone.,error.,after.orxstate.— so a user-sentdone.reviewwas invisible to"*", could not triponUnhandled: "error", and passedstrictundeclared. The engine now flags the events it mints (DoneEvent,ErrorEvent,AfterEvent, andEvent.system=Truefor its sentinels,escalateand restore) and the three checks consult that flag. A user event is user traffic whatever it is called; engine events remain exempt with no regression to the 0.8.0escalate/onUnhandledfix. The build-time reserved-namespace warning added earlier in this release is withdrawn — its premise no longer holds. - An invoked child actor costs one asyncio task, not two (#43). The
parent no longer runs a manager task per child that sat awaiting
wait_done(); completion is pushed from the child’s terminal listener the instant its status flips, and exiting the owning state stops its children directly. 50 idle children add ≤ 51 tasks over baseline (pinned), the loop schedules no timer callbacks while they idle (pinned), andonDonelatency is sub-2 ms median (pinned). TheProduction Characteristicstask budget is nowchildren + 1. send_threadsafe()appliesstrictandevent_schemas(#78, #51). It skipped_check_strict, so the recommended cross-thread path was the one without the guardrail — a typo’d event was accepted and dropped, and a payload the schema rejects drove a real transition. It now raisesUnknownEventError/InvalidEventPayloadErroron the calling thread before anything is queued, exactly likesend().actionErrorPolicy: "rollback"/"fail"withdraws eventsraised by the failed action list (#27). Rollback restores configuration and context; it cannot un-send asendTo(that effect has left the machine), but araiseis an event the machine queued for itself and had not yet processed, so it is now dropped instead of being delivered into a configuration the undone transition never reached. Events raised by earlier transitions are untouched.- The
actionErrorPolicydefault-flipDeprecationWarningfires once per process, not once perMachineNode(#27). A service building interpreters from one module-level machine used to see it exactly once, ever — typically in a warm-up path nobody reads. rollbackno longer checkpoints context on transitions that run no actions (#27). The per-transition deep copy cost ~22 % throughput on an idlerollbackmachine; it is now skipped when neither the transition, the exited states nor the entered subtree declare any action (≈ 0.98× of the default on the same benchmark).
Added
Interpreter(service_executor=),send_threadsafe(internal=),Receipt.denied,on_unhandled_eventdisposition"guard_denied",on_invalid_event/on_snapshot_errorplugin hooks.SnapshotMidStepError,SnapshotCorruptError,SnapshotSerializationError,InvalidEventError,RootTargetError— typed members of theXStateMachineErrorhierarchy for the conditions above.from_snapshot(clock=, restart_timers=),has_dormant_timers.on_resolve_error,on_plugin_errorplugin hooks;interpreter.last_plugin_error.LoggingInspector(redact_keys=, log_context=),redact(),DEFAULT_REDACT_KEYS.- Package-root exports:
is_system_event,system_event,DoneEvent,AfterEvent,ENGINE_EVENT_SHAPES. interp.last_error— the exception behind the most recentlast_transition_ok=False, on both engines, so a fire-and-forget caller can detect a failed step withoutwait=True.RunawayChainError— carried on receipts /last_errorwhen a self-generated chain exceedsmaxIterations.events.persist_event/events.restore_event— the snapshot record codec for every event class (layout v2).has_dormant_invocationson both engines (#44). After a staticfrom_snapshot()the machine reportsstatus == "running"— it is processing events — while everyinvokein the configuration is parked.statusis therefore not a liveness signal after a restore; this boolean (andpending_invocations()) is. A newstatusvalue was rejected because it would break every consumer switching on the existing four.MachineLogic(strict=True)(#52). Refuses to register an undecorated public method:InvalidConfigErrorat construction instead of an arity-based guess plus aUserWarning. Decorated methods and_privatehelpers are unaffected. DefaultFalse; behaviour unchanged unless set.- Static
raisetargets are validated at build time onstrictmachines (#51)._check_strictalready ran on theraisebuilt-in, but under the defaultactionErrorPolicy: "continue"that failure was contained like any action error — logged, hooked, transition committed — so a typo’d internal event never raised to anyone. A literal event name in the config is a configuration error;create_machine()now rejects it with aDid you mean …?suggestion. Dynamic (callable)raiseevents are still checked at runtime. ErrorEvent(#80). Service and child-actor failures are delivered as a dedicatedErrorEvent(type, error, src)instead of aDoneEventwhosedatahappened to hold an exception —onErrorhandlers can now branch onisinstance(event, ErrorEvent)or readevent.error, as in XState v5.DoneEventis used only for success (done.invoke.*,done.state.*).ErrorEvent.datastill returns the exception with aDeprecationWarningand is removed in 0.9.events.ENGINE_EVENT_SHAPES— the exact name shapes the engine synthesises (done.invoke.,done.state.,error.platform.,after.,xstate., the sentinels), for build-time checks and documentation.SYSTEM_EVENT_PREFIXESremains exported for compatibility.- snake_case ↔ camelCase logic names, everywhere. A PEP 8 Python
function now implements the camelCase name in an XState config through
every entry point —
MachineLogic(actions={"store_user": fn}),MachineLogicsubclass methods,logic_modules,logic_providers, and the Pythonic decorators. Matching is case- and separator-insensitive on both sides (normalize_logic_name), so acronyms (logHTTPStatus↔log_http_status), digits (fetchUserV2↔fetch_user_v2) and Stately’s non-identifier names (inline:m.a#entry[0]↔inline_m_a_entry_0,fetch-data↔fetch_data) all bind without an@action("…")decorator. Previously onlylogic_modules/logic_providersmapped names, via a forward snake→camel conversion that was lossy for acronyms and undefined for non-identifiers; an explicitMachineLogicdict with snake_case keys raisedImplementationMissingError. Aliases are resolved once increate_machine()(resolve_aliases) so the interpreter hot path is unchanged. An exact-name entry always wins over an alias.
Changed
actionErrorPolicy: "fail"now leavesstatus == "stopped", not"error", with the configuration cleared (#145). Code that checkedstatus == "error"after a policy halt should check"stopped"(orinterp.error is not None)."error"remains the status for an invoked service that died.guardErrorPolicy: "raise"takes the fallback candidate before surfacing the exception (#152); a machine that relied on the raise aborting the whole array now lands on the fallback.Receiptgained a fifth field,denied(#153). A positional destructure of exactly four fields now raisesValueError; read fields by attribute.AfterEvent.scheduled_for/fired_atareOptional[float]andlateness_msisOptional[float](#118):Nonemeans “not recorded”.- v1 persisted events with engine-shaped names restore as user events (#162). See Snapshots for the one-time re-persist note.
Receiptgained a fourth field,deferred(#84 in this release; flagged as undeclared by #119). A positional destructure written for 0.8.0 —state_ids, changed, error = receipt— now raisesValueError. Destructure by attribute, orstate_ids, changed, error, _ = receipt.WrongThreadErrormessage corrected (#37). It claimed events sent from a foreign thread “would be silently lost”, which was false for the correct 0.7.x idiomasyncio.run_coroutine_threadsafe(interp.send(…), loop)— that form worked in 0.7.x and is rejected since 0.8.0 because the thread check runs before the coroutine is scheduled. The message now names that idiom explicitly and points tosend_threadsafe(). This is a 0.8.0 behavioural break for previously-correct code that the 0.8.0 notes omitted; see Sending from Another Thread in the interpreters guide.- Ambiguous logic registrations are rejected. Registering two
different callables whose names differ only by case or separators
(
fetch_dataandfetchData) for a name the machine requires now raisesInvalidConfigErrorat build time instead of silently picking one. - Every Python snippet in the guides and README now uses snake_case
implementations against camelCase JSON, matching what
xsm gtgenerates.
Performance
- Hot-path work, measured on the cross-library benchmark (same host,
Python 3.14,
benchmarks/competitors/run.py; details in the PR). Nothing observable changed – every shortcut is pinned bytests/test_perf_hot_path.pyand the full parity suite.create_machine()builds the tree once: auto-discovery used to construct a throwawayMachineNodejust to collect required names, then the real one – 43% of construction time. The loader now walks the tree it is handed, and the required-name walk is memoised on the machine._accepts_kwarg(the 0.8.0-clocksync=probe run in every interpreter__init__) is memoised per function;inspect.signaturewas 32% of interpreter construction.- Static transition geometry is memoised on the
TransitionDefinition(domain / LCCA and entry path), keyed on the resolved target’s identity so live-resolved targets are never served a stale plan. The exit set stays dynamic. ~6 µs of a 21 µs flat macrostep. - No coroutine is created for an empty action list (entry, exit, transition): the sync engine’s trampoline paid two frames per entered state for nothing.
send()fixed costs trimmed:Clock.pump()returns immediately on an empty heap;_check_strictis one attribute read when not strict and no schemas; the reserved-key scan skips empty payloads; hot-pathlogger.debugcalls sit behind oneisEnabledForper macrostep.Receiptno longer deep-copiescontexton a machine that declares no actions anywhere (MachineNode.context_is_immutable): nothing can mutate it, sochangedis the configuration compare.- The async run loop yields to the event loop every
Interpreter._INBOX_YIELD_EVERY(16) inbox events instead of every one; the #48 fairness bound forcall_latertimers is now N events (microseconds) rather than one, andsend(wait=True)throughput rises ~35%. Priority and internal lanes are still checked before each take. - After the round-5 hardening (per-region configuration legality on
every snapshot, the conservative-cycle chain check, the guard-denied
flag, the receipt-hook wrappers) the hot path was re-measured with an
interleaved A/B against the pre-round-5 tree: an initial 5–9% cost was
clawed back to ~2% by inlining the split-out
_execute_selectedcoroutine, astrfast path in_prepare_event_reporting, an early return in_run_held_replays, and skipping receipt-only bookkeeping forsend(wait=False)on the sync engine. Published numbers (home page, README,benchmarks/competitors/results.json, Production Characteristics) are from a fresh clean-venv run of the final tree. - Construction and 1,000-instance fan-out – the two rows where the
table was a coin-flip against
transitions– are now won by a margin that survives harness noise (six interleaved runs in both adapter orders: 1.16-1.25x and 1.12-1.19x). Two PR-sized pieces:- Parser single-pass.
StateNode.__init__reads every optional key in oneitems()pass (_prefetch_node_keys) and hands the values to the_parse_*helpers instead of each re-probing the dict; the two post-parse whole-tree walks (_mark_subtree_actions,_scan_tree_features) are folded into the parse as post-order accumulation;_on_partialsis computed only when a.*key exists; parser / resolver / build-path INFO and DEBUG records sit behind one level check each; auto-discovery handsMachineNodea shared placeholder logic. A structural fingerprint of 154 configs is byte-identical before/after.create_machine()on the 7-node benchmark machine: 87 -> 77 us. - Thin interpreter.
__slots__onBaseInterpreter/SyncInterpreter/Interpreter(1.6 KB -> 400 B per instance;__dict__kept for subclasses and ad-hoc attributes); the deadline heap’sthreading.Lockis allocated on first push; six per-instance INFO records gated; the initon_transitionrecord is only built when a plugin is attached;StateNode.owns_taskslets entry/exit skip the task schedule/cancel round-trip for states that declare neitherafternorinvoke; init/exit trigger events are shared sentinels; a flat immutable initial context isdict()-copied. - Every row moved: flat toggle 55k -> 83k, nested 23k -> 34k, parallel
40k -> 56k, construction 8.5k -> 12.4k, 1,000 instances 27k -> 52k,
timers 8.7k -> 11.6k, async
send(wait=True)23k -> 32k. Our own per-process budget (Production Characteristics) 44k -> 59k ev/s andafterlateness at 500 busy machines 62 -> 36 ms.
- Parser single-pass.
- Net, full cross-library harness (median of 7, GC off, same session):
flat toggle 48.6k → 62.4k ev/s (+28%), nested 20.8k → 25.9k (+24%),
parallel 37.6k → 44.5k (+18%), construction 5.9k → 9.4k machines/s
(+61%), 1,000 instances 17.1k → 28.3k/s (+65%), delayed transitions
6.8k → 8.9k timers/s (+31%), async
send(wait=True)23.5k → 27.1k (+15%). Construction and 1,000-instances are now the fastest of the four libraries benchmarked.
Removed
- Dead CLI code:
generator._generate_logic_header/_generate_logic_component(superseded by thestrategies/templates in 0.7.0) andstrategies._shared.collect_all_states/collect_all_transitions/_resolve_target(superseded by the typed IR incli/ir.py). None was reachable from any command; ~390 lines.
Deprecated
- The 0.7.x sibling reading of a leading-dot target now warns (#31).
{"target": ".b"}on a state with no childbstill resolves to the sibling, but emits aDeprecationWarning(once per source/target pair) naming the unambiguous#machine.pathspelling and thestrictTargetsswitch. This was acceptance criterion 2 of #31 and did not ship in 0.8.0. The fallback is removed in 1.0.
Documentation
- Site redesign, round two. Light theme by default (dark is remembered once chosen — the previous build forced dark and persisted it on first load), emerald→teal→blue accent, darker dark mode, readable sidebar and table-of-contents active states, zebra-striped tables, theme-aware code blocks and code tabs, an orange event pulse on the landing statechart.
- Every hand-drawn ASCII diagram replaced with a live Mermaid statechart (43 across the guides) with a full-screen viewer, zoom, and consistent padding; edge labels are legible in both themes.
- New Reliability & Failure Policies guide collecting the 0.8.0 hardening surface with a runnable example per policy; FAQ grown from 19 to 36 questions; a Naming section in Core Concepts; emoji signposting on section headings throughout.
- Two pre-existing broken in-page anchors fixed (
cli,troubleshooting); the docs link checker now models kramdown and GitHub slugging separately. - Mobile pass. Tables are wrapped in a scroll container with a sticky
first column (the old
display:blocktable gave scroll but brokewidth:100%, so rows shrank to content on every screen size); phone breakpoint tightens the type scale and gutter, stacks the hero CTAs, and separates the three floating controls that shared one corner. The Requirements table now lists Python 3.9 – 3.14.
[0.8.0] — 2026-09-17 — Fortify (Current Release)
Adoption-readiness, parts 1–3.
Adoption-readiness. A production adoption audit (tracking issue
#26) filed 34
defects against 0.7.0 with a common theme: the library fails silently by
default. Part 1 closed all four blockers and the filer’s top priorities.
Part 2 (below, marked [wave 2]) closes the remaining small/medium items:
actor lifecycle, persistence envelope, invoke.input, the pure API’s cost,
hierarchical value, and the production-characteristics documentation.
Part 3 (below, marked [wave 3]) closes the concurrency and correctness
items: the SCXML-correct internal event queue, a bounded inbox with
overflow policies, send(wait=, priority=) receipts, resumable
invocations after restore, an injectable clock with a starvation-free
timer lane, strict-mode event validation, and a refactor that now runs
both engines off one core algorithm.
Every new behaviour is a per-machine policy or an additive API whose default
preserves 0.7.x semantics, with two deliberate exceptions called out under
Changed.
Added
actionErrorPolicy: "continue" | "rollback" | "fail"(#27). Before, an action that raised left the transition committed with a half-built state.rollbackrestores configuration and context;failrolls back and stops withTransitionFailedError. Newon_transition_failedplugin hook andinterpreter.last_transition_ok. The default (continue) emits a one-shotDeprecationWarning; it flips torollbackin 1.0. The policy covers every action slot –entry,exit, the transition’s ownactions, targetless and internal self-transitions, and the initial entry performed bystart()– and a rollback cancels anyaftertimers or invokes that a partially-entered target state had already armed.onUnhandled: "ignore" | "defer" | "error"(#28).deferis library-owned: replay is at the head of the queue in original order, still-unhandled events are re-deferred, the buffer survives snapshots and is bounded byDEFER_MAX.interpreter.deferred_count, newon_unhandled_eventhook (fires under every policy) andUnhandledEventError.guardErrorPolicy: "false" | "true" | "raise"(#35). A raising guard is now observable viaon_guard_errorbefore the substituted result is reported; previously it was indistinguishable from a guard returningFalse.- Build-time validation (#29, #30).
create_machine()now walks the finished tree and rejects, in one message, every transition target that does not resolve and everyalwaysself-target that can never make progress.create_machine(..., strict_targets=False)downgrades target failures to aDeprecationWarning; that escape hatch is removed in 1.0. strictTargets: truemachine config (#31) disables the sibling fallback for.childtargets.Interpreter.send_threadsafe()(#37) for delivering events from a foreign thread.send()from a foreign thread now raisesWrongThreadErrorinstead of silently losing the event.- Error-observability hooks on
PluginBase(#33):on_transition_failed,on_guard_error,on_unhandled_event,on_error,on_done. All implemented byLoggingInspector. Existing plugins load unchanged. - Built-in action param validation (#32).
raise,sendTo,cancel,stopChild, … now fail at build time when a required key is missing, with a hint if the key was placed at the top level instead of underparams. - New exceptions exported:
UnhandledEventError,TransitionFailedError,WrongThreadError. - [wave 2]
interpreter.value(#58) – the active configuration in XState’s hierarchical form: a leaf key for an atomic root,{parent: child}for compound (innermost collapses to a string), one key per region for parallel,{}beforestart(). Tree-walked, so state keys containing.are safe.matches()now also accepts a partial value dict. Snapshots carry a derived"value"key; restore ignores it. - [wave 2] Snapshot envelope v1 (#45). Persisted snapshots gain
version(integer payload-layout version, bumped only on layout change),machine_id,machine_hash(a 16-hex structural fingerprint over states, transitions, guard/action names, invokes and delays – stable acrossmeta/descriptionedits and key order) andtaken_at.from_snapshotrefuses a newerversionwithSnapshotVersionErrorand a mismatched id or hash withSnapshotDriftError;from_snapshot(..., verify_machine_hash=False)opts out after a migration. Unversioned 0.7.x payloads restore exactly as before. New modulepersistence.pyowns the format contract. - [wave 2] Inbox durability (#47), both engines:
interpreter.pending_events(accepted-but-unprocessed, FIFO),drain_pending()(remove without processing),stop(drain=True)(process to empty; async engine also takestimeout=). Snapshots carrypending_eventsand restore re-enqueues them, recursively for child actors. - [wave 2]
invoke.inputmay be a callable (#42) –fn({context, event})(XState form) orfn(context, event)– resolved per spawn viaInvokeDefinition.resolve_input(), deep-copied, and passed to a child MACHINE as its creationinput(previously it was never forwarded at all), so a childcontextfactory receives{input}as in XState. A plain-dict child context receives it only atcontext["input"]– declared keys are never overwritten. A raising resolver becomesonErroron both engines. - [wave 2]
Interpreter.wait_done()(#43) – a future resolved the instant the machine reachesdone/error. - [wave 2]
spawnBlockingTimeoutmachine key (ms) bounds how long aspawn_blocking_<key>waits for the child (#41). Default 30 s; the wait is never unbounded, so a child that never reaches a final state cannot wedge its parent. - [wave 2] Docs: Production Characteristics (#53, #56) – a new guide
page with measured numbers for the per-process throughput budget,
aftertimer lateness under load, and theSyncInterpreterthreading contract, plusbenchmarks/production_characteristics.pyto reproduce them. - [wave 3] SCXML internal event queue (#36) – a zero-delay
raiseto self during a macrostep now goes to a dedicated internal queue that both engines drain to completion before taking the next external event, instead of sharing one queue with the outside world. Trace order is now['entry', 'RAISED', 'EXTERNAL'], not['entry', 'EXTERNAL', 'RAISED']. Chains of raises stay FIFO;alwaystransitions still run first within each microstep. - [wave 3] Bounded inbox (#38) –
Interpreter(max_queue_size=, overflow_policy=OverflowPolicy.*)(RAISEthe default once a bound is set,BLOCK, orDROP_NEWEST).RAISEraisesQueueOverflowError;DROP_NEWESTwarns and calls the newPluginBase.on_event_droppedhook. Newinterpreter.queue_depthon both engines for observability.max_queue_size=Nonekeeps the unbounded queue (default, unchanged). - [wave 3]
send(wait=True)/send(priority=True)(#39) –wait=Trueresolves to aReceipt(state_ids, changed, error)once the macrostep for that exact event has run, so a caller can gate on the machine’s decision without polling.priority=True(alsosend_priority()) delivers ahead of the inbox and is exempt from its bound. A dict-form payload using the reservedwait/prioritykeys still works but emits aDeprecationWarning. New exports:Receipt,OverflowPolicy,QueueOverflowError,InterpreterStoppedError. - [wave 3]
from_snapshot(restart_services=True)andpending_invocations()(#44) – restoring a snapshot is still a static rebuild that starts nothing by default, butpending_invocations()now lists everyPendingInvocation(state_id, invoke_id, src)in the active configuration with no live service or child actor, andrestart_services=Truere-invokes each of them from scratch (not resumed) through the same path_enter_statesuses on both engines. - [wave 3] Injectable
Clock(#48, #49, #50) –Clockprotocol,RealClock(default) andSimulatedClock(virtual time), passed asInterpreter(clock=)/SyncInterpreter(clock=); invoked and spawned children inherit the parent’s clock.RealClocknow delivers a firedaftertimer through a priority lane the async run loop checks ahead of the inbox, so a due timer can no longer be starved behind a burst of external events;AfterEventgainsscheduled_for,fired_at, andlateness_ms.SyncInterpreterno longer spawns an OS thread peraftertimer or delayed send – a due deadline is delivered on the caller’s thread at the top ofsend(), in the macrostep loop, or by the newtick(). - [wave 3] Strict mode (#51) –
strictmachine config key orInterpreter/SyncInterpreter(strict=)constructor flag (ctor wins). Under strict,send()of an event type the machine has never declared raisesUnknownEventErrorsynchronously at the call site, before the event is queued, with a difflib suggestion ('Did you mean FILL?').MachineNode.is_known_event()applies the same matching rules as dispatch, including partial ('mouse.*') and bare-'*'descriptors.create_machine(event_schemas={'FILL': Fill})adds opt-in, dependency-free payload validation – any object withvalidate(payload)or__call__– raisingInvalidEventPayloadErrorat the call site regardless of the strict setting. Default (strict unset, no schemas) is unchanged.
Deprecated
actionErrorPolicy: "continue"(the default) (#27). Emits a one-shotDeprecationWarning; it flips to"rollback"in 1.0.create_machine(..., strict_targets=False)(#29, #30). Downgrades unresolvable transition targets to aDeprecationWarninginstead of raisingInvalidConfigError; that escape hatch is removed in 1.0.
Fixed
.childtargets resolve into the source’s descendants, matching XState v5; the 0.7.x sibling reading is kept as a fallback (#31).internal: false(XState v4 spelling) is honoured asreenter: trueinstead of being silently dropped (#29).sendTocan address an invoke by its explicitidand bysystemId; a duplicate livesystemIdraisesActorSpawningError(#40).from_snapshotdeep-copies the persisted context and merges it over the machine’s defaults instead of aliasing the caller’s dict (#46).@action/@guard/@servicemarkers win over arity-based auto-registration inMachineLogicsubclasses; ambiguous arities warn (#52).- Resolving a transition no longer writes back into the shared
TransitionDefinition(#59). #machineId.pathtargets resolve when the machineiditself contains a dot ("my.machine"); previously the first dotted segment alone was compared against the key and every such target was unresolvable.- The unresolvable-target error names the absolute
#machine.pathform of any nested state matching the bare name, so a 0.7.x machine that relied on the fuzzy fallback gets the one-line fix in the message. - Engine-synthesised
xstate.*events (e.g.xstate.error.actor.*fromescalate) are treated as system events by theonUnhandledpolicy, the same asdone.*/error.*/after.*. UnderonUnhandled: "error"an unhandled escalation no longer stops the parent with a misleadingUnhandledEventError. SyncInterpreter: replayed deferred events no longer count against the macrostep runaway budget, so replaying a fullDEFER_MAXbuffer cannot trigger the overflow guard and discard live events queued behind it. The async engine already behaved correctly. (0.8.1 note: plain external events were still counted and could be discarded — see #77 above; the two engines agree as of 0.8.1.)- Two tests in the suite declared a target as a sibling of
"states"; the new validator caught them. - [wave 2] Runtime target resolution no longer falls back to a
whole-tree search by last id segment (#34). A bare
target: "filled"declared in one parallel region used to bindaudit.archive.filledin an unrelated region and move it. Resolution is now strictly lexical (sibling /#id/.child/ exact top-level key) in both engines, which now share ONE resolver; the validator mirrors it one-for-one. - [wave 2]
spawn_blocking_<key>on the asyncInterpreterhonoured only thespawn_half and ran non-blocking (#41). Both engines now wait for the child to reach a terminal status before the parent’s next action; the sync engine also waits out a child driven byaftertimers, which it previously did not. - [wave 2] The pure API (
transition/get_next_snapshot) built a fresh interpreter subclass per call and deep-copied twice, costing 4x a realsend()(#54). One probe per machine per THREAD is now cached (thread-local, so concurrent callers never share one) and reset; measured ~3x faster. Semantics unchanged.
Changed
- Per-event
INFOlog calls on the hot path are nowDEBUG(#55, part 1). Measured overhead of running atINFOon the filer’s OMS machine dropped from 2.53× to ~1.0×. Interpreter.send()is a regular method that does all of its work eagerly – thread check, normalisation, status guard and the queue put – and returns an already-resolved awaitable soawait interp.send(...)is unchanged. A fire-and-forgetinterp.send("GO")from inside the loop is therefore delivered rather than silently dropped, and no “coroutine was never awaited” warning is ever emitted by the library.send()/send_threadsafe()on an interpreter whose event loop has since been closed raise aRuntimeErrorthat says so, instead of aWrongThreadErrornaming the same thread on both sides.- [wave 2] Reaching a top-level final state now tears down (#57):
child actors are stopped,
aftertimers and invoked services cancelled, and the machine’s actor-system registration removed – the momentstatusbecomes"done"(or"error"), not whenstop()is later called.status,output,errorandcontextare retained;stop()on a done machine is a quiet no-op that keepsstatus == "done". Machines that relied on children outliving a completed parent must restructure (that dependence was on a leak). - [wave 2] Invoked child actors no longer poll (#43). The parent
awaited
child.statusevery 5 ms in a second task; it now awaits a completion future.onDonelatency drops from a 5 ms floor to ~0, which can expose tests that used the delay as a settling window._ACTOR_POLL_INTERVALis removed. Interpreterno longer constructs itsasyncio.Queuein__init__; the queue is created whenstart()binds the loop, and events sent beforestart()are buffered and delivered in order. On Python 3.9asyncio.Queue()binds to the current loop at construction and raised when built outside one, so anInterpretercould not previously be instantiated in synchronous code on that version.- [wave 3] Hot-path work, both engines – roughly +40-55% events/s
on every machine shape, measured on the same laptop: sync flat 23.5k ->
35k ev/s, sync nested 25k -> 38k, async fire-and-forget 21k -> 29.5k,
send(wait=True)15k -> 18.5k. Four build-time answers replace per-event work: transition targets are resolved once by the build-time validator and memoised on theTransitionDefinition(the runtime re-ran the full multi-strategy resolver per transition);_record_historyis skipped on machines that declare no history state; the transient-settle pass that ran a full transition selection after EVERY event is skipped on machines with noalways; single-leaf configurations skip a sort. No semantics change – the full suite is unchanged and each fast path has a pinned “slow path still taken when needed” test. Consequences visible in Production Characteristics: per-process budget ~20k -> ~30k trivial ev/s,afterlateness at 500 busy machines ~63 ms -> ~46 ms. - [wave 3] Documentation is executed in CI.
tests/test_docs_executable.pyruns every ```python block in README.md and docs/_guide/*.md that imports the package (a block opts out with a visible<!-- doc-fragment -->marker) and resolves every guide cross-link and anchor, so a sample that stops running or a link that 404s on the site fails the build. 34 runnable feature examples now live underexamples/*/features/, one per capability, all executed bytests/test_examples.py. - [wave 3] Real type safety for users (
py.typedwas already shipped; now the types are worth having). Verified bytests/test_type_safety.py, which type-checks representative USER programs with mypy and pyright and asserts every real bug is flagged and no correct line is:create_machine(..., context_type=MyCtx)– aTypedDictor anyMappingsubtype – flows through tointerp.context, so a typo’d key or wrong value type is a checker error. No runtime effect; without it the context isDict[str, Any]as before.TContextis bound toMapping[str, Any]:SyncInterpreter[MyCtx]with aTypedDictwas a type ERROR under the oldDictbound.- The unused
TEventtype parameter is gone:Interpreter[Ctx], notInterpreter[Ctx, Any]. It appeared in zero signatures. send(..., wait=True)types asReceipt(sync) /Awaitable[Receipt](async);wait=andpriority=are checked asboolinstead of being swallowed into**payload.from_snapshot()andSyncInterpreter.start()return their own class, not the base.MachineLogiccallables pin arity and the guard’sboolreturn: a two-argument action or a guard returningstris now a type error.BaseInterpreteris exported for annotating plugin hooks.- The library itself is at zero mypy errors (was 61) and zero pyright errors; mypy runs in the CI lint job.
- [wave 3] CLI: four more generated-code defects from executing the
104-machine corpus. (1)
"guard": "!name"– Stately’s shorthand for a negated guard – was taken literally and demanded a guard called!name; the engine (GuardDefinition) and the CLI IR now desugar it to{"type": "not", "children": ["name"]}so the stub emitted isname. (2)onDoneon a compound/parallel STATE was skipped by the logic extractor, so its guard/actions were never stubbed. (3) A machineidthat is also a stdlib module name (token,queue,email, …) producedtoken.py, which shadowed the stdlib moduleloggingimports and died mid-import with an unrelatedAttributeError; such stems get a_machinesuffix. (4)camel_to_snakewas ASCII-only, so every Cyrillic/CJK/accented name collapsed to the fallbackmachineand each generated method overwrote the last; identifiers now keep Unicode letters (PEP 3131). Also: two different config names that sanitise to the same identifier (fetch-data/fetch.data) are de-duplicated (fetch_data,fetch_data_2) by one shared allocator that every emitter and every reference site read from. - [wave 3] Generated code binds Stately
inline:action names (CLI). Stately exports anonymous actions asinline:machine.state#entry[0]; every template turned that into an identifier-safe method name and then relied on name matching (@action-> camelCase;LogicLoader-> method name / camelCase), which can never reproduce a name with:,.,#or[. The generated code compiled and imported, butstart()raisedImplementationMissingErroron 26 of the 104 real-world corpus machines. All five templates now emit@action("<original>")when the name does not round-trip (ordinary camelCase names are unchanged), andLogicLoaderhonours that marker for bothlogic_modulesandlogic_providers– so a hand-written provider can implement such a name too. Found by executing, not just importing, every generated file. - [wave 3]
RestoredErroris exported from the package root. It is whatinterpreter.errorholds after restoring a snapshot taken in theerrorstatus, and the docs showed it as importable, but it was missing from__all__– found by executing every documentation sample. - [wave 3]
OverflowPolicy.BLOCKself-send deadlock (#38). Asend()issued from inside an action while the bounded inbox was full suspended the run loop – the only consumer of that inbox – forever, withstatusstill"running". A send issued during a macrostep is now routed to the internal event queue (#36 semantics), so it is processed before the next external event instead of blocking. - [wave 3] Rollback now stops actors spawned by the failed
transition (#27, #60). Under
actionErrorPolicy: "rollback"aspawn_*action that succeeded before a later action raised left its child running and registered although the transition was undone. - [wave 3] Children inherit the parent’s
clockandstrict(#49, #51), spawned or invoked, on both engines. A child spawned by the sync engine was built with a freshRealClock, so aSimulatedClock-driven parent could not advance its children’saftertimers; and on both engines a child fell back tomachine.stricteven when the parent had passedstrict=Trueto its constructor. - [wave 2] Pure API: history no longer leaks between calls (#54).
The cached probe reset everything except
_history, so a history target in oneget_next_snapshot()call resolved to wherever an unrelated earlier call had exited. History now travels WITH thePureSnapshot: chained calls keep resolvingp.histto where that chain leftp; an unrelated or hand-built snapshot resolves it to the default child. - [wave 3] One core algorithm, two execution strategies (#60). The
step, transition-execution, state-entry/exit, lifecycle-action, and
built-in-action logic is now implemented once on
BaseInterpreter;SyncInterpreterinherits it unchanged and drives each coroutine to completion synchronously instead of re-implementing it as a parallel set of plain-defmethods. No behaviour change is intended – an action trace is now pinned byte-identical across both engines by test – other than incidental bug fixes already released in earlier wave-3 commits (e.g.functools.partial-wrapped async actions on the sync engine now raiseNotSupportedErrorinstead of having their coroutine silently discarded).
For full details, see the [0.8.0] section of CHANGELOG.md.
[0.7.0] — 2026-08-12
The code generator rewrite. Three of the five templates — every
pythonic-* one — produced machines that did not match their source JSON,
on inputs as simple as a two-state machine. Two failed silently, exit code 0.
Round-trip fidelity across the 104-machine real-world corpus went from
0/104 to 103/104 for all three. The one exclusion has no states key and
is rejected by create_machine() too.
If you generated code with pythonic-class, pythonic-builder or
pythonic-functional on 0.6.0 or earlier, regenerate it. Run
xsm generate-template <file.json> --template <id> --diff to see what changes.
Fixed
pythonic-functionalproduced machines with zero transitions — every machine it ever generated could start but never move.State.to()returns aTransition; emitting it as a bare expression discarded it.pythonic-buildersilently dropped every nested state, so the generated code ran as a different machine.pythonic-classfailed outright withMultiple initial states.- Colliding names (
"my-state"/"my_state") collapsed into one variable, destroying a state. final,after,always,parallel,history,tagsandmetawere dropped by all three templates.- Composite guards (
and/or/not) were never extracted, so leaf guards were never stubbed and machines died withImplementationMissingError. - Named delays (
after: {"BACKOFF": …}) were never collected. - Python keywords and non-ASCII names produced invalid Python.
Added
- Round-trip verification. Generated code is compiled, executed, and
compared structurally against
create_machine(source_json)before anything is written. A mismatch prints what diverged and exits 1. --check/--diff— exit 1 when on-disk files differ from what would be generated. Makes generated code safe to commit.- Provenance header — source JSON, template, version, regeneration command.
- Support matrix in
xsm list-templates. State(history=…),State(tags=…),State(meta=…),build_machine(root=…)andMachineBuilder.root()— machine-levelon,entry,exit,tagsandtype: parallelwere previously unrepresentable.
Changed
- Generated code passes
black --checkandpyflakescleanly. - Runners now demo a reachable event path instead of alphabetical order.
- Removed the
await asyncio.sleep(0.1)placeholder from async action stubs. - The Pythonic API no longer raises where the JSON engine merely warns: a
compound state with no
initial, and afinalstate with outgoing transitions, are now accepted with a warning.
[0.6.0] — 2026-08-10
Added
- XState v5 feature parity — every gap in
docs/FEATURE_GAP_ANALYSIS.mdclosed. - Built-in action creators —
assign,log,raise_,send_to,send_parent,choose,pure,enqueue_actions,spawn_child,stop_child,cancel,emit,escalate,forward_to. See Actions. - Actor system —
spawnChild,sendTo,systemIdregistry addressable from any actor, andsystemIdpersistence across snapshots. See Actor Model. - Pure API —
initial_transition,pure_transition,get_next_snapshotandPureSnapshotcompute transitions with no side effects. - Waiting helpers —
wait_for,wait_for_sync,to_promise. See Testing & The Pure API. - Composite guards —
and/or/notandstateIn. - Named delays, state
tags,meta, and machineoutput. - PEP 561 —
py.typedis now shipped, so inline annotations reach mypy.
Fixed
Repairs to the SCXML transition algorithm and a family of correctness defects found by an adversarial battle test. Highlights:
- Transitions are atomic. A raising action previously left the machine with
zero active states while still reporting
running. - The async run loop survives per-event errors instead of dying silently and dropping every later event.
- Deep history into a parallel state no longer activates two leaves in one region.
- Invoked child machines fire
onDoneonly on a real top-level final state,onErroron failure, and are always torn down (previously leaked). - Runaway
raisechains are bounded on both engines. - Entry/exit actions receive the real triggering event on
SyncInterpreter(previously a synthetic event with an empty payload). - Custom state
idnow resolves#myIdtargets; plugin errors are contained; malformed configs raise actionableInvalidConfigError.
Changed (behavioural — see the migration notes)
- Action errors are contained;
.send()no longer re-raises them. start()on a stopped interpreter raises instead of silently no-opping.- A state key containing
.whose first segment is also a sibling is rejected.
[0.5.0] — 2026-03-23
Added
- Pythonic API — three new styles for defining state machines in pure Python:
StateMachinebase class with metaclass (class-based declarative API)MachineBuilderfluent builder APIbuild_machine()functional API withStateobjects
@action,@guard,@servicedecorators for marking functions with automatic name mapping (snake_case to camelCase)State.to()transition API with|operator for combining transitionsState.internal()method for internal transitions (no state change)State.enter()/State.exit()decorators for entry/exit action registration- CLI
--templateflag with 5 code generation templates:pythonic-class—StateMachinesubclasspythonic-builder—MachineBuilderchainpythonic-functional—build_machine()callclass-json— class-based with JSON at runtime (default)function-json— module functions with JSON at runtime
- Strategy pattern architecture for CLI code generation (easily extensible)
- Rich generated code with type hints, docstrings, error handling (try/except), and logging
- 143 Pythonic API tests across 20 test classes
- Stress test suite with 50 real-world XState machine configs
- Comprehensive documentation overhaul (25 guide pages)
Changed
_resolve_target()signature updated with context-aware resolution for nested states- Generated code now uses PEP 8 snake_case function names with auto-mapping to camelCase
- Template selection replaces the old
--styleflag - Default async mode is template-dependent: sync for Pythonic templates, async for JSON templates
Fixed
- Nested state target resolution when using dot-path references
- State/event name collision in generated code (event variables now get
_eventsuffix) - Empty actions list emission in generated transition code
- Conditional
servicedecorator import (only imported when services exist) - Function complexity compliance (flake8 C901) in generator code
- Windows console encoding errors with emoji characters in CLI output
Deprecated
--styleflag (class/function) — use--templateinstead. Maps toclass-json/function-json. Will be removed in v0.6.0.
[0.4.3] — 2025-02-03
- Python 3.14 support
- Build system migration to
uv
[0.4.2] — 2025-08-13
reenterflag for self-transitions (forces exit/re-entry)
[0.4.1] — 2025-07-27
- Enhanced sync actor spawning in
SyncInterpreter - Hierarchical machine generation in CLI (
--json-parent,--json-child) - CLI subcommand aliases (
gtforgenerate-template)
[0.4.0] — 2025-07-16
- CLI tool introduction (
xsm generate-template) aftertransition support inSyncInterpreter
[0.3.x]
- Plugin framework (
PluginBase,LoggingInspector) - Snapshot system (save/restore interpreter state)
- Actor spawning (
invokewith machine sources) - Dual execution engines (
Interpreter+SyncInterpreter)
[0.2.x]
LogicLoaderwith auto-discovery (snake_case → camelCase mapping)logic_providersandlogic_modulessupport increate_machine()- PyPI packaging and distribution
[0.1.0]
- Initial release
- XState JSON parsing and validation
- Async interpreter with full statechart support
- Hierarchical states, parallel states, final states
- Guards, actions, services
after(delayed) andalways(eventless) transitions