Changelog
Release history and what changed in each version.
All notable changes to XState-StateMachine for Python are documented here.
For the full changelog with commit history, see CHANGELOG.md on GitHub.
This project follows Semantic Versioning for the core and persistence APIs; contrib extras are provisional. See the Deprecation Policy for how deprecated APIs are warned about and removed.
[Unreleased]
Changed
- The django-fsm and LangGraph comparison pages are re-checked against
their current releases (django-fsm-2 4.3.0, langgraph 1.2.20). Every
row was re-verified against the installed package: django-fsm-2 4.3.0
is still a flat, sync-only state field with no timers, nesting, actors,
REST helpers or XState import (the release is typing work, an
exceptionsmodule and an optional django-unfold admin integration), and LangGraph 1.2.20 still has subgraphs as nodes,Sendfan-out, per-nodetimeout=/TimeoutPolicy/RetryPolicyandinterrupt(), with no compound/parallel state type and no durable timers.docs/_data/comparisons.json, every source note and the “checked” line name the new releases; no library code changed.
Fixed
- The two regressions the nightly integration perf gate had been
reporting are fixed, so its plugin and persisted rows are back to their
reference. Both are pinned by shape (not by a timing assertion) in
tests/test_perf_regressions_307.py; the opt-in budgets test keeps measuring the microseconds.plugins_sync(1.7-2.1x) andplugins_async(1.3-1.6x) measured above their hardware-adjusted reference on every runner model since the #261/#262 battles (2026-10-02).IdempotencyPluginkeeps its processed-key ring (64 ids) and evidence map incontextso they travel inside the snapshot, and everywait=Truereceipt and everyon_event_processeddeep-copied the whole context – the ring and its evidence twice per event (~630deepcopycalls per event under cProfile).__xsm_*context keys are reserved for the library and are now excluded from_context_before_image()and from the receipt’s “changed” compare (an action that writes only them is not a context change); the ring and evidence are updated in place instead of being rebuilt per event. Same-machine A/B (2 plugins, 3,000 keyed sends): plugin row ~750 us to ~220 us per event.persisted_memory_sync/persisted_sqlite_syncmeasured 1.26-1.44x their reference on some runners since 0.12.0. The restore-option guard calledinspect.signature()on EVERYpersisted()/apersisted()round trip (~40 us on a laptop; a quarter of the profiled round trip). The accepted keyword names are a fixed library API and are resolved once at import. Same-machine measurement: aMemoryStoreround trip ~340 us to ~210 us.
[0.12.1] - 2026-11-07
Changed
SyncInterpreter.send()does about half the Python calls per event on a guarded self-transition (66 to 32 under cProfile): the admission, mailbox, timer-pump and held-replay checks are read at the call site instead of calling helpers that return early; a single named-event transition that neither exits nor enters a state (targetless, or an atomic self-transition withoutreenter) takes an actions-only microstep that skips the planning passes; a guard without params is called directly; the rollback queue-depth probe runs only under a non-"continue"actionErrorPolicy. Semantics are unchanged and pinned bytests/test_sync_send_fast_paths.py. On the cross-library benchmark the guard + action row goes from behindtransitions(the 2026-11-07 refresh below); seebenchmarks/competitors/README.md.-
The documentation draws its diagrams itself. Stately now asks every visitor to sign in before it shows a hosted machine, so the embedded editor frames on the docs site were blank. The 245 charts in the guides and the example gallery are generated from each machine’s JSON (
scripts/diagram_mermaid.py→docs/assets/data/diagrams.json) and rendered in the browser by Mermaid 12 (ELK layout, so transitions are routed orthogonally as in Stately) in the Stately notation:entry /andexit /action chips and the invoked service inside the state box, event pills with theirifguard and actions,afterdelays,done:/error:service results, a final-state mark, dashed parallel regions. Every chart keeps its Open in Stately link and the Full screen viewer and follows the site theme; in dark mode the charts use the Stately editor’s own palette.scripts/check_diagrams.pyrenders all of them headlessly and fails on any overlapping, touching or clipped element. No library code changed. FileStoreno longer rewrites a lock file’s holder stamp on every acquisition. The stamp (<pid> <wall time>from byte 1 of the key’s.lockfile, read by waiters for theholder=onLockTimeoutErrorand for the stale-lock retry) was written inside the critical section on every lock, and a modified file is rescanned by on-access antivirus when its handle closes: on Windows 11 with Defender real-time protection a lock cycle cost 9.9 ms median (15.8 ms p90) against 0.14 ms for the same open/lock/unlock/close without the write, paid by everysaveand again by everyOptimisticLockretry. The stamp is now read first (a read does not dirty the file) and left alone when it already names the acquiring process and is younger thanstale_lock_after / 2; a stamp by another pid (live or dead), an unparseable one, an older one or a future-dated one is rewritten as before. Same machine after the change: lock cycle 0.18 ms median (0.25 ms p90),savewithfsyncoff 26 ms to 10 ms (the rest is the record file’s own rescan). The stamp still names the live holder; its timestamp is now the holder’s last refresh, at moststale_lock_after / 2before the current acquisition, so a hold shorter thanstale_lock_after / 2(30 s by default; it was the wholestale_lock_after) can never read as stale to a waiter. A stale verdict only shortens a waiter’s retry sleep from 10 ms to 1 ms; the OS lock stays the truth. Cross-process stale-lock reclamation and the documented holder info are unchanged.
Fixed
- Diagram colours follow the theme in every diagram type. The generated
statecharts were always recoloured by the stylesheet, but the hand-written
mermaidfences in the guides (sequence diagrams, flowcharts) were drawn with a dark Mermaid palette in both themes: in light mode their message text was pale on pale (measured 1.35:1). Mermaid’s theme is now built from the same--sc-*palette the statecharts use, and drawn figures are redrawn when the theme toggle changes. - A chart is never drawn below 75% of its natural size. Mermaid draws an
SVG at
width="100%", so a wide chart was squeezed into the column: a 2,300px chart at 16%, a 2,752px flowchart at 760px with labels about 5px tall. Past the floor the chart keeps its natural size and the figure scrolls sideways, the way a wide code block does. -
scripts/check_fence_diagrams.pyrenders all 57 hand-written fences in both themes at a page and a phone width and fails on low-contrast text (below 3:1 is an error, 3-4.5:1 a warning), clipped labels and unreadable downscaling;scripts/check_diagrams.pygained the same contrast rule and a--widthphone check. - Benchmark table corrected and re-measured. The 0.12.0 notes claimed the
guard + action row for XState-StateMachine (1.14x
transitions); on the unchanged 0.12.0 code three quiet sessions puttransitionsahead by about 1.26x (its 2026-11-05 figure of 83,360 ev/s was a low sample against 127,000-131,000 since). The benchmark README keeps that corrected 0.12.0 table as the previous one. The release table itself is the 2026-11-07 refresh below. - The pure-vs-sync timing test counts the pure API’s real per-step allocation
(inbound and outbound context copies, the rebuilt snapshot sets) in its
floor, so a cheaper
send()no longer pushes it towards its limit. - The
xsmconsole no longer keeps the firstsys.stdoutit saw. AConsolebuilt without an explicit stream (the process-wide one thatget_console()caches, which every subcommand writes through) boundsys.stdoutonce, at construction. A host that redirected output after the CLI package was first used (a test runner’s per-test capture, a server or management command that swapssys.stdout) then saw that output land in the old stream, or gotValueError: I/O operation on closed fileonce the old stream was closed; in this repository’s own suite threeinspect/replay/simulatetests passed or failed depending on which test ran first. The console now lookssys.stdoutup on every write (error(to_stderr=True)already readsys.stderrat call time),Console.outis a settable property, and a stream passed explicitly (Console(stream=...)) stays bound exactly as before. A probed console also re-probes its colour, glyph and animation capabilities when the stream it follows changes, so a swap to a file or pipe stops emitting ANSI and box glyphs. - Benchmark refreshed against the latest competitor releases.
python-statemachine 3.2.2 (from 3.2.1) and sismic 1.6.14 (from 1.6.11)
were re-measured with transitions 0.9.3 on a quiet machine; every row keeps
its winner (guard + action 1.63x and the statechart rows
4.8-5.2x the next library, construction 1.31x and
1,000 instances 1.33x over
transitions).results.json, the benchmark README and the home page carry the new table.
[0.12.0] - 2026-11-05
Behaviour changes in this release. The statechart engine now follows XState v5 where it used to differ, the Pythonic DSL gained an instance façade, and a few things an existing chart or class relied on behave differently. Read Changed before upgrading: the behaviour changes there say who notices and how to migrate.
Added
- Pythonic DSL, second generation. A
StateMachinesubclass is now a running machine:sm = Light()starts it (sync engine),sm.cycle()/sm.send("CYCLE", amount=3)send events and return the actions’ return values, andsm.state,sm.configuration,sm.context,sm.status,sm.can(),sm.matches(),sm.allowed_events,sm.green.is_activeand a readablerepr()answer the questions a caller has. The recommended lifecycle idiom iswith Light() as sm:/async with Light() as sm:(stops on exit, exceptions included); for an instance that outlives a block,sm.interpreter.start()/sm.interpreter.stop()are the explicit escape hatch.Light.create_machine()is unchanged and still returns the bareMachineNode. - Events are named by the attribute:
cycle = green.to(yellow)is theCYCLEevent (slow_down→SLOW_DOWN);event=remains an override. Transitions declared inside nested state classes are exposed as event methods on the machine (order.ship()). - Naming-convention hooks:
on_enter_<state>,on_exit_<state>,on_<event>,before_<event>,after_<event>and the genericon_enter_state,on_exit_state,on_transition,before_transition,after_transition. A hook whose suffix matches no state or event logs a warning instead of silently never running.before_*/after_*need the instance façade (create_machine()warns when they exist) and must be plaindef(anasync defhook is anInvalidConfigError). Convention bindings and inferred event names are recorded per class, never written into theState/Transitionobjects, so module-level states can be shared between machines. - Signature-based argument injection for every class-based and
functional callback: declare only what you need (
context,event,event_type,interpreter,action_def,error,data,input,source,target,state, any payload field,**kwargsfor the rest). The legacy positional shapes ((interpreter, context, event, action_def),(context, event[, params]),(interpreter, context, event)) are detected by arity and keep working unchanged, a default in the trailing slot (params=None,a=None) and extra defaulted parameters after the shape (level="info") included. A callback whose arity matches a legacy shape is resolved by name only when it asks for injection – a default anywhere but that trailing slot, a keyword-only parameter,**kwargs(which receives the payload fields once the call is resolved by name), or a parameter named like a field the event carries, a chartparamskey or a hook’s injectedstate– and every parameter without a default can then be supplied:def can_pay(self, context, amount=0), anddef can_pay(self, context, amount)with anamountin the payload, are injected;def is_big(self, ctx, e, params=None)anddef log_it(self, i, ctx, e, a=None)are positional, as aredef ok(self, c, e)anddef log_it(self, i, c, e, a)even when the payload carries a key namedeora. An engine name in another slot than its own (paramsas an action’s fourth parameter,stateas a guard’s first) always switches the callback to by-name resolution. When a callback with no engine name asks for injection and the payload names some of its parameters but not a required one (def fetch(self, url, retries=3, timeout=10)sentretries=5and nourl), the missing one is aTypeErrorthat names it; otherwise the positional call happens as before. Two ambiguities remain. A legacy callback whose non-engine parameters without a default are all named like fields the event carries is resolved by name:def ok(self, ctx, e)withsend("GO", e=1)bindse=1(a defaulteda=Nonethen takes its default). And a default on the event parameter of a legacy shape (def ok(self, ctx, e=None)) asks for injection, soeisNonewhenever the event carries noefield. Name such parametersevent/interpreter/action_defif your payloads can carry those keys, and keep the event parameter without a default. State.Compound/State.Parallelaliases for the nested-class form, and nested classes now accept everyState(...)keyword (after=,on_done=,history=,tags=,entry=,invoke=, …). A nested class is a valid transition source and target.strict = Trueon aStateMachinerefuses undeclared events (UnknownEventError) and declared events that no active state will take right now (newTransitionNotAllowedError), at the call site, without dispatching or stopping the machine.- Build-time logic validation: instantiating a
StateMachineraises oneImplementationMissingErrorlisting every guard, action and service the chart names but no method implements, instead of one name at a time at the firstsend().validate_logic = Falsedefers the check to runtime;create_machine(validate=True)applies it to a bareMachineNode(off by default there, because a chart without logic is a legitimate static artifact forto_json(), diagrams and codegen). MachineNode.to_json(indent=None)serialises the XState configuration the machine was built from (raisingTypeErroron a value JSON cannot represent rather than stringifying it). The Pythonic API guide had advertised it since 0.5.0; it did not exist.- A subclass of a
StateMachinesubclass inherits its states, transitions and callbacks; attributes it redefines win. Before, the base’s chart was silently dropped and the subclass compiled to an empty machine. engine = "sync" | "async" | "auto"on aStateMachine;autopicks the asyncioInterpreterwhen any callback isasync def. The façade also acceptsclock=(aSimulatedClockin tests) andplugins=.- Services see the triggering event. An invoked service’s
inputfactory now receives the event whose transition entered the state (XState’sinput: ({context, event}) => ...; it used to getNone), verified against xstate 5.33.3. A state that declares noinputstill passes the service{}(XState passesundefined): forwarding the payload implicitly would make the same Stately export behave differently under this engine than under XState. To forward it, declare it –"input": event_payload(new, exported from the package root; the JSON equivalent isinput: ({event}) => event) or anylambda ctx, ev: ...factory.@state.service(input=event_payload, ...)is the class-DSL spelling, and argument injection of payload fields into a service’s parameters works through that declared input. Child machines invoked as actors follow the same rule. Both engines. - Plain methods satisfy string references by name.
guard="can_pay"(or"canPay") plus an undecorateddef can_pay(self, amount)now binds, as doactions=["reserve"]andinvoke={"src": "charge_card"}– for names the chart references and no decorated callable already provides, so helper methods are never swept in. The decorators remain the explicit form and the only way to register under another name. @state.service(on_done=..., on_error=..., id=..., input=...)decorator on aState: the method becomes the service AND the state gains the matchinginvokeentry, so the two cannot drift apart.SyncInterpreter.from_state(machine, state, context=...)/Interpreter.from_state(...)build an interpreter that is already in a given state – a dot path relative to the root ("paid.packing"), an absolute id, or several for parallel regions – without running entry actions, exactly like a snapshot restore (it is one: a minimal snapshot handed tofrom_snapshot, so the same legality checks apply). A compound or parallel state completes to its initial leaves; a top-level final state restores asdone.StateMachine(state=...)exposes the same on the façade. This is the “state string stored on a model row” case that previously needed a hand-builtPureSnapshotwith its ancestor set.- Docs: “Coming from python-statemachine” and “Coming from transitions”
migration guides, each with a concept map, a worked traffic-light port
(guard, nested state,
aftertimeout) shown in the other library and as an executable block in ours, and “what maps 1:1 / what differs / what you gain” sections. Linked from the comparison pages and the integrations journey. - Docs:
jekyll-sitemapis enabled anddocs/robots.txtpoints crawlers at the sitemap. tests/test_battle_310_pythonic_dsl.py: battle tests for the new surface – 200 concurrent async sends keep their results apart, cross-threadsend_threadsafeinto a façade, hooks that raise, five levels of nesting with deep history and a simulated-clockafter, same-named leaves at every level, snapshot round-trips of façade-built machines, three-level inheritance, every callback signature shape in one machine, a 200-state machine with the compiled-config cache, the bounded result sink, and lifecycle misuse.
Changed
Behaviour changes. The statechart algorithm now matches XState v5 in
the places listed below, where it used to give a different answer. Each
item was checked against xstate 5.33.3 by the conformance cases the Rust
engine shares with this library. Both engines (Interpreter and
SyncInterpreter) change together. Machines that never relied on the old
answers are unaffected; tests that pin the order of entry or exit actions
in parallel or nested charts may need their expected lists updated. The
entries after the engine ones say what an existing Pythonic StateMachine
class, a plugin or a subscribe() listener may notice.
- Exit actions run in reverse document order, one region at a time.
When a transition leaves several states, they now exit in reverse
document order: descendants before their ancestors, and in a parallel
state the whole region declared last before the region declared above
it (for regions
zetathenalpha:a1,alpha,z1,zeta). Before, every leaf exited first and then every region, with ties broken by how the state ids sort, so regions interleaved and renaming a state could reorder cleanup. XState and SCXML define the exit order this way. If one region’s exit actions must run before another’s, declare that region later in the chart. - States are entered in document order. A transition’s whole entry
set (the target, its ancestors and every default child) is computed
first and entered in the order the chart declares the states. A
transition into one region of a parallel state used to enter the
other regions first and the targeted region last (
e_p,e_second,e_s1,e_first,e_f2); it now runse_p,e_first,e_f2,e_second,e_s1, as XState and SCXML do. To make one region’s entry actions run before another’s, declare it earlier. - A transition into a sibling region resets the whole parallel state’s
regions. A transition whose target lies in another region of the same
parallel state (
#m.p.r2.dfromm.p.r1.a) has the parallel state as its domain: every region exits (reverse document order) and every region is entered again – the targeted one through the target, the others through theirinitialstate. The parallel state itself is neither exited nor re-entered. Before, only the targeted region exited and re-entered, and the source region kept its state. This is the XState v5 / SCXML transition domain (the least common ancestor, which may be a parallel state). To move only the other region, handle the event inside that region (every region sees every event, sor2.c: {on: {E: "d"}}keepsr1untouched), orraisean event that region handles. reenter: truere-enters the source even when the target is its child. A re-entering transition frompto its own child.anow exitsp’s active child andpitself, runs the transition actions, then enterspandaagain (x_b,x_p,hard,e_p,e_a). Before,reenteronly mattered for a transition targeting its own source, and this one leftpactive (x_b,hard,e_a). In XState v5reenter: truemoves the transition domain above the source, so the source exits and re-enters whatever the target. A re-entering transition declared on the machine root likewise runs the root’s ownexitandentryactions. Dropreenter: truewhere the source’sentry/exitactions must not run again.- Completing the machine runs the remaining exit actions. When the
machine reaches a top-level final state, the
exitactions of every state still active – the final state just entered and the machine root included – now run in reverse document order, after the final state’sentryactions and afteroutputis resolved (x_a,e_done,x_done,x_root). This also happens whenstart()lands directly on a final state. Before, completion ran no further exit actions. XState v5 does this as part of the step that completes the machine; the configuration is unchanged (a done machine still reports its final state). Cleanup that should run only on cancellation, not on normal completion, belongs in anexitaction of a non-final state, or check the triggering event in the action. "*"and"prefix.*"also receive engine events. Wildcard and partial descriptors now match the events the engine generates (done.invoke.*,error.platform.*,after.*,done.state.*), as XState v5’s descriptor matching does. A state’s ownonDone,onErrororafterhandler still outranks a"*"on the same state; a"*"on a deeper state outranks an ancestor’s handler, exactly as for ordinary events. A service failure that only a"*"(or"error.*") handler catches is now consumed there and the machine keeps running; before, the wildcard never saw it and the machine went tostatus == "error". If a catch-all handler should ignore engine events, give it a guard that checks the event type (for examplelambda ctx, ev: not ev.type.startswith(("done.", "error.", "after.", "xstate."))), or declare explicitonDone/onErrorhandlers on the same state as the wildcard or deeper – a wildcard on a child state outranks an ancestor’sonDone,onErrorandafter, so a child’s"*"can keep a parent from ever seeing its service’s completion.- Multi-target transitions are supported.
targetmay now be a list of states that lie in different regions of one parallel state ("target": ["#m.p.r1.x", "#m.p.r2.y"]); each region enters its target instead of itsinitialstate, in document order. Before, a list target was rejected withInvalidConfigError ... multi-target lists are not supported. XState v5 and SCXML allow exactly this shape. Any other list – two targets in one compound region, a target and its own descendant, the same state twice, targets not under one parallel state – is still anInvalidConfigErroratcreate_machine(not downgradable bystrict_targets=False); a one-element list is a plain target.TransitionDefinition.target_strs(new) holds every target andresolved_targetstheir resolved nodes;target_str,resolved_target, the structural hash (machine_hash) and diagram exports use the first target, so the hash of every chart that built before is unchanged and matches the Rust engine. The[pydantic]config gate and the published JSON schema accept a list of strings. - The JSON
chooseaction runs its first matching branch. A{"type": "xstate.choose", "params": {"branches": [{"guard": ..., "actions": ...}, ...]}}action now evaluates its branches in order and runs the actions of the first whose guard passes (a branch without a guard always passes); branch guards may be composite (and/or/not) orstateIn. Before, only theparams.conditionsspelling that thechoose()helper builds was read, so a JSONchoosesilently ran no branch. This is how the XState translation ofchoose(enqueueActionswithcheck) and the Rust engine behave. ThestateInguard also reads XState’s ownparams.stateValuekey (as well asstate/value); before,{"type": "stateIn", "params": {"stateValue": "#m.p.mode.night"}}was always false.choose()andparams.conditionskeep working unchanged, andstub_logic()/logic_names()now include the action and guard names insidechoosebranches. - Transitions taken for one event form one microstep; conflicting ones
are dropped. When several parallel regions take a transition for the
same event, every state any of them exits is now exited first (reverse
document order), then each transition’s actions run (regions in document
order), then every state any of them enters is entered (
exitC,exitA,left,right,enterB,enterD). Before, each transition ran its own exit, actions and entry before the next one started (exitA,left,enterB,exitC,right,enterD). Two of those transitions conflict when their exit sets overlap – a region whose transition targets another region exits every region – and only one is taken: the one selected first in document order, unless the later one’s source is a descendant of the earlier one’s, in which case the more deeply nested transition wins. Before, the second transition still ran whenever its source was active again after the first one (for example after the first transition had exited and re-entered it), and a parallel state’s own handler leaving the parallel state could still run after its regions’ transitions. This is XState v5’sremoveConflictingTransitionsandmicrostep. The transitions are also selected in document order of the regions, not deepest-first. A rollback (actionErrorPolicy) undoes the whole microstep, andon_transitionfires once per transition taken, after all of them, with the configuration before and after the step. done.state.*is an internal event. When a final state completes its compound (or parallel) parent, the generateddone.state.<id>event now goes onto the internal queue, like araise, so the parent’sonDonetransition is taken before any external event that is already waiting. Withsend_events(["FINISH", "NEXT"])(or twosend()calls queued on the async engine before it runs),FINISHcompletes the state,onDoneruns, andNEXTis handled in theonDonetarget; before, the done event was appended to the inbox behindNEXT, soNEXTwas handled in the completed state first. XState v5 raises it into itsinternalQueue. A rollback (actionErrorPolicy) of the step that entered the final state now also withdraws the done event it queued, and on the async engine the event counts toward themaxIterationschain budget like any other self-generated event.- Guards are evaluated in XState’s order, and only when needed.
Transition selection now follows XState v5’s
selectTransitions: the active tree is descended region by region (document order); in each region the innermost active state’s candidates are tried in order and evaluation stops at the first enabled one; a state’s own handlers are consulted only when nothing below it matched, and a parallel state’s only when no region matched. Before, every active leaf walked up to the root evaluating the guard of every matching transition on the way (memoised per pass), so guards XState never evaluates ran – an ancestor’s, or a later candidate’s on the same state – and a parallel state’s own targetless handler for an event ran next to a region’s transition for it.alwaystransitions followselectEventlessTransitions: each active leaf walks up to its first enabledalways, so a guard on analwaysshared by N regions is evaluated N times per pass (XState’s count); before it was evaluated once and the pass then selected a second time to execute. Guards with side effects and theon_guard_evaluatedhook see the new count and order;can(event)evaluates exactly whatsend(event)would.stub_logic()takes a newguard_log=list that records every guard evaluation. - One
assigncomputes every property against the context before it.assign({"a": lambda args: args["context"]["b"], "b": 1})now givesathe oldb: all property values (plain or callables of{context, event}) are resolved first and then merged into the context, as XState v5’sassigndoes, so two keys can swap without a temporary. Before, each property was written as soon as it was computed and later callables of the sameassignsaw the earlier writes. Separateassignactions are unchanged: they run in order (exit, transition, entry) and each sees what the previous ones wrote. To derive a key from another key’s new value, split theassignin two or return the whole update from one callable (assign(lambda args: {...})). - A compound state targeting itself resets its children. A transition
from a compound (or parallel) state
ptopwithoutreenternow exitsp’s active descendants, runs the transition’s actions and entersp’sinitialchild again (x_b,t,e_a);pitself is neither exited nor entered, so its ownentry/exitactions do not run and itsaftertimers keep running. Before, such a transition only ran its actions and the active child stayed. This is XState v5’s transition domain (the source) andcomputeEntrySet. An atomic state targeting itself is unchanged (actions only), andreenter: truestill exits and re-enterspas well. To keep the children where they are, make the transition targetless (droptarget). - A JSON
assignwithout theassignmentwrapper updates the context.{"type": "xstate.assign", "params": {"count": 1}}(or"type": "assign") now assignscount;params.assignmentis used when present, otherwiseparamsitself is the property mapping – the spellings the Rust engine and the XState translation of the dialect accept. Before, only{"params": {"assignment": {...}}}(whatassign()builds) was read and the bare form was silently ignored, so the same chart produced a different context on this engine. A chart that relied on the bare form being a no-op should remove the action. alwaystransitions are checked in the order their states were entered. After a step, the eventless pass now visits the active innermost states in the order they were entered – a state that stayed active before one the step entered, states entered together in the chart’s order – as XState v5’sselectEventlessTransitionsdoes (it walks its snapshot’s state list, to which a microstep appends what it enters). Before, they were visited in document order. This changes the order in whichalwaysguards run and, whenalwaystransitions of two regions conflict, which one is taken: after regionr2moved todand thenr1moved tob,d’s transition now wins overb’s. A step that re-enters exactly the states it left without recording history keeps the previous order, and a restored snapshot (or a hand-builtPureSnapshot) starts in document order; snapshots produced by the puretransition()carry the order to the next call. The snapshot format is unchanged. If one region’salwaysmust win a conflict, make the guards exclusive instead of relying on the order.- History is recorded only when its owner is exited. A history
node’s state now remembers its active descendants only when that state
itself is exited, as XState v5 and SCXML (
exitStates) do. Before, every transition that exited any descendant of it recorded too, so afterg1→g2insidesandg→k(still insides), a transition fromktos’s deep history restoredg2; it now enters the history default (s’sinitialchain,g1), becauseswas never left. Oncesis exited, its history is recorded and restored exactly as before. Snapshots are unchanged in shape; one taken after such an inner transition no longer carries the extrahistoryentry. To resume an inner state from inside the parent, target that state directly. - An
alwaystransition that changes nothing runs once, not in a loop. After an eventless microstep that left the active states, the recorded history, the context and the spawned actors as they were, thealwaystransitions are not selected again until an internal event has been processed, as XState v5’smacrostepdoes (it re-selects only when the microstep produced a new snapshot). Analwaysthat targets its own state – atomic, withreenter: true, or compound (its children exit and re-enter) – or a targetless one with a plain action now runs its actions once per step (once more perraised ordone.state.*event). Before, it ran again and again untilmaxIterationstripped aRunawayChainErroron every step. An action that mutatescontextin place counts as a change (Python’s equivalent ofassign), so a guarded countingalwaysstill loops until its guard fails; an uncopyable context is conservatively treated as changed. done.state.*reaches the states XState notifies, and only those. Entering a final state now raisesdone.state.<parent>for its compound parent and then, walking up,done.state.<p>for every parallel statepwhose regions are all in final states (a history child is not a region), in that order and whether or not the state declaresonDone– XState v5’senterStates. Before, only the FIRST ancestor that had anonDoneand counted as “done” was notified, and a compound counted as done when its active child did, recursively. So: when the last region of a parallel state finishes, the region’sonDoneand the parallel state’sonDonenow both run (before, only the region’s); a compound state whose child compound completes (a→b→ finalf) no longer runsonDone(b’s runs,a’s does not, as in XState); adone.stateevent without a handler reaches a"*"handler; and a machine whose root is a parallel state completes when every region is final (before, it stayed running). Plugin hooks and audit trails (the transition log, the Django/DRF history) now also see thedone.state.*event of every region that finishes. To react to a nested completion higher up, put theonDoneon the state that owns the final child, or transition to a final child of the outer state from there.- A
StateMachineinstance owns a few attribute names. A subclass is now a running machine (see Added), sostatus,state,context,configuration,machine,interpreterandallowed_eventsare read-only properties of every instance. A class whose__init__or methods assigned one of them onself–self.status = "fresh"– now raisesAttributeError, also throughcreate_machine(), which still runs the user’s__init__. Rename the attribute (self._status). - Naming-convention hooks bind existing methods too. An undecorated
method named
on_enter_<state>,on_exit_<state>,on_<event>,on_transition,on_enter_stateoron_exit_stateis now called by the engine. A helper that carried such a name in an earlier release runs once more per entry, exit or transition; one whose parameters cannot be resolved raises inside the action list, which is logged as an action error and skips the explicitly declared actions after it. Rename the helper, or give it parameters the engine can inject. internal: falsewith a child target re-enters the source.internalis the XState v4 alias ofreenter("internal": falsemeansreenter: true), so thereenterchange above applies to it: a transition frompto its own child with"internal": falsenow exits and re-entersp(itsexit/entryactions run, itsaftertimers and invokes restart). Before, neither spelling re-entered for a child target. XState v5 ignoresinternalaltogether; drop the key where the source must stay.subscribe()listeners are notified once per microstep. When one event takes transitions in several parallel regions, a subscriber now sees one notification with the settled configuration instead of one per region transition, each showing an intermediate configuration. Plugins’on_transitionstill fires once per transition taken, after the whole step (see the microstep entry above). Diff successive snapshots instead of counting notifications.- A declared falsy
inputreaches the service as declared. Aninvokewhoseinputis0,"",[]orFalsenow passes that value; before it was coerced to{}. Only an undeclared (ornull)inputbecomes{}. -
xstate_statemachine.pythonicre-exports only its own names. With the implementation moved to private modules (below), names the module merely imported –MachineLogic,MachineNode,InvalidConfigError,ActionCallable,GuardCallable,ServiceCallable– are no longer importable from it, and neither is the private_StateMachineMetametaclass (now in_pythonic_class); import them from the package root (from xstate_statemachine import MachineLogic) or their own modules. Every documented DSL name is unchanged; the DSL classes’__module__is now a private module (xstate_statemachine._pythonic_coreforStateandTransition,_pythonic_classforStateMachine,_pythonic_builderforMachineBuilder). State.to()andState.internal()no longer raise whenevent=is omitted; the event may be inferred from the attribute name. Compiling a transition that still has no event (functional API,build_machine()) raisesInvalidConfigErrorat build time instead.send,state,configuration,context,status,can,matches,allowed_events,machine,interpreter,engine,strict,plugins,validate_logicand the existing class settings are reserved attribute names in aStateMachinebody and raiseInvalidConfigErrorif used for a state or transition.startandstopare deliberately not reserved – timers and media players need those events and Stately exports use them – but the lifecycle always wins: astoptransition stays in the chart (sm.send("STOP")) and gets nosm.stop()sugar, and a warning is logged at class definition. Letting the event win would have turned a documented shutdown call into a machine that keeps running silently.import xstate_statemachineis lighter: about 25-30% less import time with warm bytecode and 47 fewer modules loaded. The graph algorithms (reachable_states,shortest_paths, …), the pure transition API (wait_for,get_next_snapshot, …), the testing utilities (stub_logic,logic_names), the receipt codec and the file / SQLite / async store backends are imported on first use (PEP 562 module__getattr__).__all__and everyfrom xstate_statemachine import Xare unchanged and type checkers still see every name. Those submodules are no longer attributes of the package after a bareimport xstate_statemachine–xstate_statemachine.helpers.wait_forraisesAttributeErroruntilxstate_statemachine.helpersis imported; import the name from the package root or import the submodule explicitly.hashlibanduuidare loaded only when a structure hash or an anonymous actor id is first needed.tests/test_import_time.pypins which modules a bare import may load.- Docs: the Quick Start is one recommended path – a
StateMachineclass run withwith Machine() as sm:– followed by “Also: load XState JSON or design in Stately”, “Choose your engine” and “Next steps”; the older Python styles are kept in a compact section. Getting Started leads with the class DSL. xstate_statemachine.pythonicis now a thin public module; the implementation moved to_pythonic_core,_pythonic_builder,_pythonic_classand_pythonic_runtime. Every documented DSL name, and the private compiler helpers the test suite imports, is re-exported unchanged; the incidental imports listed in the entry above are not.
Pre-release review pass (behaviour changes). A line-by-line
review of the whole library ahead of 0.12.0 – engine, config
parsing, runtime utilities, Pythonic DSL, persistence, EDA and
inspector, CLI and every contrib adapter – changed the following
behaviours. Each item is pinned by a regression test under
tests/test_review_fixes_*.py (and the contrib / CLI / EDA /
persistence equivalents).
- Engine: exit actions now run before the state’s invoked services and
child actors are stopped, as in XState v5
exitStates, so an exit action can stillsendToits own invoke (both engines). - Engine: on
SyncInterpreter, a non-blocking child’s completion (onDone/onError) and itssendParentran the parent’s step on the child’s pump thread, racing the owner thread. They are now delivered on the owner’s thread at its nextsend()ortick(). A parent-to-childsendTostays synchronous, serialised with the child’s pump step. - Config parsing: a state
typeoutsideatomic/compound/parallel/final/history("Final","paralel") and acompound/parallelstate with no child states now raiseInvalidConfigErrornaming the state and value. A mis-casedfinalused to build an atomic state whoseonDonenever fired, and a misspelledparallelbuilt a compound state. An atomic, final or history state that declares children is still built as compound, now with an accurate warning. - Config parsing: an invalid
historyvalue ("dep") raisesInvalidConfigErrorinstead of silently falling back to shallow history, and a history state’s defaulttargetmust be a non-empty string. - Config parsing: the boolean flags
strict,strictTargets,strictConfigand the transition-levelreenterandinternalaccept onlytrue/false(or null for ‘not set’). They were read withbool(), so"strict": "false"enabled strict mode and"reenter": "false"forced a re-entry. - Config parsing: config 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 ...) instead of the bare ‘Guard must be a string or a dictionary’. - Config parsing: an action object without a
typeraisesInvalidConfigError(it used to become an action named “UnknownAction”), and non-objectparams(5, a list) isInvalidConfigErrorrather than a bareTypeError. UnderstrictConfig, unknown keys of action objects, guard objects and a built-in’sparams("delya") are reported like other unknown keys (a warning otherwise). - Config parsing:
maxIterationsmust be a whole number >= 1 (0, negatives,2.9andtruewere accepted or truncated) andspawnBlockingTimeoutmust be a finite number >= 0 ("nan","inf"and negatives were accepted). - Config parsing: an
initialthat names no child state now raisesInvalidConfigErroratcreate_machine(), with a ‘did you mean’ hint, matching XState’screateMachine. It used to fail only atstart(). - Config parsing:
afterkeys must be whole, non-negative milliseconds or a delay name.1.5was truncated to 1,-5andtruewere accepted, and two keys that normalise to the same delay ("100"and" 100") silently overwrote each other. Numeric strings such as"1e3"are normalised and the generatedafter.<ms>.<state>event uses the normalised value. - Config parsing: invoke
idandsystemIdmust be non-empty strings,descriptionmust be a string, child state keys must be non-empty strings, an empty-stringtargetis refused (it was a silent targetless transition), andentry/exit/actionsof0,falseor""are refused instead of meaning ‘no actions’. - Runtime utilities:
register_globalraisesTypeErrorfor a class or an object without anyon_*hook instead of silently accepting it.ExplorationLimitErroris now defined inexceptionsand exported from the package root.receipt_to_jsongainedinclude_messages=and the status lookup follows the error’s MRO;STATUS_NO_OPis the new name ofSTATUS_NOT_MODIFIED_OK.SimulatedClockgained typedadvance()/advance_sync(). - Runtime utilities: every
PluginBasehook docstring now states its engine, thread and what a plugin may do;on_event_droppeddocuments all seven reasons (EVENT_DROP_REASONS). The package-level docstring example now actually prints. - Pythonic DSL:
after_<event>andafter_transitionhooks run once per event, after its whole macrostep has settled. Before, they ran once per microstep, so an eventlessalwaysfollow-up ran them twice. A raising hook is still contained and cannot veto the transition (now documented). - Pythonic DSL:
xstate_statemachine.pythonic.__all__lists only public names. The underscore helpers (_compile_config,_snake_to_camel, …) remain importable but no longer leak throughimport *. - Persistence:
fingerprint()refuses payload values whose text form embeds a memory address (previously a falseIdempotencyMismatchErroron retry);adopt.from_state_idsraisesInvalidConfigErrorfor a non-JSON context instead of stringifying it;list_versions(limit=-1)raisesValueError;persisted()/apersisted()are typed to yield the interpreter instead ofAny. - CLI:
xsm coverage --jsonwrites--fail-underfailure lines to stderr, so redirected stdout stays one JSON document. - Web adapters: the operationId stability note now states exactly when ids can change (a look-alike event name added later may renumber an existing one).
- Django & SQLAlchemy:
PermissionGuard(object_level=False, fallback_global=False)now raises ValueError instead of silently denying every row; Djangosignalsno longer imports Django’s private_make_id. - Engine:
last_error/receipt.errorfor asendTo,forwardTo,sendParentorescalatethat reached no live actor now carryUnresolvedTargetError, a new subclass ofActorSpawningError(which is what the step reported before, although nothing was being spawned);except ActorSpawningErrorhandlers keep working. - Runtime utilities:
RealClockandSimulatedClockgainedpump_filtered(owner_filter), a pump restricted to one owner’s deadlines;Clock.pump()is unchanged. - Factory:
create_machine()warns (UserWarning) whenlogicis passed together withlogic_modules/logic_providers– the explicitlogicis used and the modules are not scanned, which used to happen silently;event_schemasvalues must be callable or expose a callablevalidate(), checked at build time instead of failing on the first matchingsend(); the alias warnings (storeUser/store_user) now name the caller’s line, not a line inside the library. - Performance. Building a machine is about 25 % cheaper and starting an
interpreter about 20 % cheaper than in 0.11.0, with unchanged semantics:
the parser reads each node’s and transition’s keys in one pass, the
build-time validator consumes the parser’s list of targeted transitions
instead of re-walking the finished tree, logic discovery skips its matching
indexes for a chart that names no logic, the async-shape and
parameter-count checks on user callables are memoised,
validation.walk()is iterative, and an idleSyncInterpreteris ~1.4 KB smaller (its mailbox and finished-children queues are lists). A flatsend()on the sync engine pumps the clock once instead of twice, skips the reserved-keyword scan for a plain string event and probes theon_event_processedhook once per drain, and the microstep epilogue calls its subscriber, completion and soft-error helpers only when there is something for them to do (both engines); the pure transition API (transition(),get_next_snapshot()) no longer normalises an event twice per step and binds its recorder’s imports once. The cross-library numbers inbenchmarks/competitors/README.mdand on the home page were re-measured on this release.
Fixed
- Transitions declared inside a nested
class paid(State): ...body were silently dropped; children declared both as class attributes and in a parent’sstates=[...]were compiled twice (once at the root); a nested class used as a.to()target raisedAttributeError. - Two states with the same name under different parents received each other’s transitions: the class-based compiler keyed transitions by bare state name. They are keyed by the source state’s path now.
MachineNode.to_mermaid()andto_plantuml()now draw what a statechart diagram needs and previously omitted: delayedaftertransitions (created --> cancelled : after 100ms, orafter TIMEOUTfor named delays, with[guard]when guarded), a--> [*]end marker on everytype: finalstate (inside the parent’s block for nested finals), and the--divider between the regions of atype: parallelstate.invokeonDone/onErroredges are drawn too (invoke onDone,invoke onError), and eventlessalwaysedges carry analwayslabel (plus[guard]) instead of an empty one. Existingon/onDoneedges are unchanged.- A three-argument legacy guard
(context, event, params)on a transition that declares noparamsreceived two arguments and raisedTypeError(evaluating asFalse); it now receivesparams=None. - A strict
StateMachinefaçade refuses a send to a finished or stopped machine withInterpreterStoppedErrorinstead of a silent drop. - Docs: Getting Started showed a four-command
xsm --help(the CLI has 24) and a project tree of modules that do not exist (machine.py,snapshot.py,plugin.py,diagram_exporter.py); both now match the shipped CLI andsrc/. Supported-Python ranges on the integration pages read 3.9 - 3.14 and now read 3.9 - 3.15. The site search index had a missing object boundary that dropped the “vs python-statemachine” entry (it appeared under the Step Functions title). State(entry="name"),State(exit="name")and.to(..., actions="name")with a bare string were iterated character by character into nonsense action names. A string is one action, as in XState JSON.- The Pythonic API guide’s
storeErrorexample read the deprecatedevent.dataon an error event; it now readsevent.error. The hierarchical guide’sFormWizardreferenced a service and an action it never implemented.
Pre-release review pass. The same review fixed the following defects; each has a regression test.
- Engine: every alternative of a state-level
onDonelist is now a transition candidate on both engines, so a failing guard falls through to the next entry (the config-parsing fix above kept them all; the engines used to read only the first). - Engine: on
SyncInterpreter, a non-blocking child that shares its parent’sSimulatedClockfired the PARENT’s due deadlines from its own pump thread. Such a child now pumps only the deadlines it owns; the parent’s fire on the parent’s nextsend()/tick(). - Engine: an initial
contextholding an uncopyable handle (athreading.Lock, a DB session) made every interpreter construction raiseTypeErrorfromcopy.deepcopy, throughcreate_machineand the class façade alike. The copy now falls back to a shallow one, sharing the handle, and logs a warning; acontextfactory gives each interpreter its own. - Runtime utilities: auto-discovery skipped
@staticmethods on a logic provider, never bound a user function named like a built-in action (log,cancel,stop, …) even though user actions win over built-ins in an explicitMachineLogic, and demanded an ACTION implementation for aspawn_<key>inside an invoke’sonDone/onError. Static methods are discovered; a built-in-named function DEFINED in a scanned module (not merely imported there) or implemented by a provider is bound and wins; spawn actions route toservices[<key>]everywhere. - Pydantic:
validate_machine_jsonnames the nesting cap on pydantic-core 2.50.4 and later too: its error type for a chart that out-nests the validator changed fromrecursion_looptorecursion_depth_exceeded, which the gate reported as a raw 200-segment “Recursion error” path instead of the named limit. - Django & SQLAlchemy: a management command that the CLI refused
(
xsm_diagram -o/xsm_docs -ointo an unwritable path, a chart that does not load) raisedCommandError("exit status 1")and left the reason in the command’s stdout. The error now repeats the CLI’s own message (exit status 1: cannot write ...: FileNotFoundError: ...), and the write-failure messages name the exception class. - Engine: an action that failed inside
choose,pureorenqueueActionsignoredactionErrorPolicy. Underrollback/failthe transition committed with partial context andlast_transition_okstayed True. Nested failures now roll back or stop the machine exactly like top-level ones (both engines). - Engine: an entry action that failed on a top-level final state under
actionErrorPolicy: rollback/failleft the machine reportingstatus == "done"with the configuration rolled back and its children already torn down. Completion is now applied only when the microstep commits: a rollback keeps the machine running, andfailstops it (both engines). - Engine: on
SyncInterpreter, araiseinside an initial-statealwaystransition was processed in the middle of the start-up microstep and lost, sostart()ended in a different state than the async engine. The settle now runs behind the re-entrancy guard, so the raised event runs after the microstep, as on the async engine and as when the samealwaysis reached by an event. - Engine:
subscribe()listeners were never notified for targetless (actions-only) transitions, so a counter’sassignnever reached a UI bound throughsubscribe(). They are now notified once per such microstep (both engines). - Engine:
has_dormant_timersreported False as soon as any restored timer was re-armed, even when another parallel region’s restored timer was still dormant. It now checks each state, and a timer that fired but whose guard refused the transition is not reported as dormant. - Engine: restoring a snapshot did not pass
minimum_version,expected_machine_hashorpluginsto child actors, so a nested child record could declareversion: 0to skip the drift check, and child restore refusals never reachedon_invalid_event. - Engine:
restart_timers="resume"/"fire_due"raisedStateNotFoundErrorfor an orphaned deadline only after it had armed some timers and discarded every parked deadline. It now refuses before arming anything, so the records survive for a retry or a re-persist. - Engine:
send("")was accepted and treated as the engine’s internal eventless sentinel. It now raisesInvalidEventError, as the dict form already did. - Engine: keyword payload passed with an
Eventobject (send(Event("X"), source="retry")), or with an__xstate_event__adapter that returns an Event, was silently dropped. It is now merged into the event’s payload; for an engine event, which cannot be annotated,InvalidEventErroris raised. - Engine: an
assignfunction returning something other than a mapping (a list, a model object) was silently ignored. It is now an action error handled byactionErrorPolicy. ReturningNoneremains an explicit no-op, and any Mapping is merged. - Engine:
sendParentandescalateon a machine without a parent dropped the event with only a log line. Like an unresolvedsendTo, they now fireon_event_dropped("unresolved_target")and mark the step failed (both engines). - Engine: an inline delay callable that raised escaped out of state
entry, whereas a named one was contained. Both are now contained. A delay
of
True, NaN, infinity or a non-number is now rejected with an error log on every path (after, delayedraise, delayedsendTo), not justafter. - Engine: a snapshot’s
taken_atusedtime.time()instead of the clock’swall_now(), so withSimulatedClock(wall_start=...)it did not line up with the snapshot’s persisted deadlines. - Engine: on
SyncInterpreter, events still queued after the machine reached a final state or was stopped by an action kept being processed, running user actions on a finished machine. The drain now stops at the terminal status, and stranded events are reported viaon_event_dropped. - Engine:
SyncInterpreter.stop(drain=True)called from inside an action did nothing. It now finishes the events queued in the current drain and then stops the machine. - Engine: events in the internal queue at
stop(), and inbox or priority-lane events left behind when the async machine completed or failed, were discarded withouton_event_dropped. Every stranded event is now reported, on both engines. - Engine: on
SyncInterpreter, asend_threadsafe()event that raised while being drained aborted the owner’s unrelatedsend(), losing the rest of the mailbox batch and the owner’s own event. The error is now logged and recorded onlast_error, and draining continues. - Engine: on
SyncInterpreterwith aRealClock, a non-blocking child’s pump thread fired the parent’saftertimers and delayed sends off the parent’s thread. Each such child now has its own deadline heap. - Engine: on
SyncInterpreter, a blocking spawn (spawn_blocking_*) of a child driven byaftertimers always waited the fullspawnBlockingTimeoutand returned with the child unfinished. The waiting thread now drives the child’s timers and returns as soon as the child finishes. - Engine: on the async
Interpreter, an action registered as an object withasync def __call__, or a plain function returning a coroutine, never ran and the step reported success. Such actions are now awaited. OnSyncInterpreter, a returned coroutine raisesNotSupportedErrorlike any async action. - Engine: the async
send_events()ignoredOverflowPolicy.BLOCK(raisingQueueOverflowErrorinstead of waiting), the owning-thread check and the self-send routing. It now applies everythingsend()does to each event. - Engine: a non-
ExceptionBaseExceptionescaping the async run loop setstatus = "stopped"directly. That skipped teardown (leaking services and child actors) and leftsend(wait=True)awaiters hanging. The machine now entersstatus == "error", fails pending receipts, fireson_errorand tears down. - Engine: a failed
start()left the async machine “stopped” without releasing what the partial entry had armed, and left the sync machine “running” on a half-entered configuration. Both engines now end “stopped” and tear down. - Engine:
SyncInterpreterfired noon_service_start/on_service_done/on_service_errorhooks for invoked child machines. On both engines, an unhandled failure while bringing up an invoked child (for example, a raisinginputresolver) left the parent running instead of failing it. - Engine:
SyncInterpreter.send_events()ransend_threadsafe()events and already-due timers after the batch instead of before it, assend()does.drain_pending()now also returns events still waiting in the mailbox. - Engine: re-starting a restored async interpreter in
doneorerrorstatus firedon_interpreter_startevery time; it now fires once. - Engine: a delayed
sendToto another actor on the async engine reported a failed delivery only as “Task exception was never retrieved”. It is now tracked and logged. - Engine: after an
actionErrorPolicyrollback, the rememberedhistoryfrom the undone transition was kept (and could be persisted), and timers and invokes were re-armed in an unpredictable order. History is now restored, and re-arming follows document order. - Engine:
sendTo("kid")from inside an actor namedkidmatched that actor’s own grandchildren, and a child keyedparentcould shadow the real parent. - Config parsing: building a machine with an
invokeno longer configures the host application’s ROOT logger.InvokeDefinitionused the module-levellogging.debugandlogging.warninghelpers, which calllogging.basicConfig()and so attached a stderr handler and made the application’s own laterbasicConfig(level=...)a no-op. It now logs through the library’sxstate_statemachine.modelslogger, on both engines. - Config parsing: a state-level
onDonegiven as a LIST of guarded transitions kept only the first entry. Every alternative is now parsed (StateNode.on_done_transitions), validated and auto-discovered, so a failing guard falls through to the next alternative as in XState v5.StateNode.on_doneremains the first one. - Config parsing: a user action named like a built-in (
cancel,stop,log,emit, …) and given its ownparamswas rejected atcreate_machine()with the built-in’s ‘missing required param’ error, although user actions resolve before built-ins. The built-in params check, and thestrictConfigparams check, now skip names the machine’s logic implements (both engines). - Config parsing: restoring a snapshot that holds a pending engine
completion (
done/error/after) emitted aDeprecationWarningaboutevents.engine_*, or raised under-W error. The restore path now uses the private minters. - Config parsing:
MachineNode.get_state_by_id()found nothing when the machine id or a state key contained a dot ("my.machine","v1.0"), and a miss now logs at DEBUG instead of WARNING. - Config parsing: a state with a dotted custom
id("my.id") could not be targeted as#my.idor#my.id.child. The resolver now matches the longest dotted prefix, as it already did for the machine id. Failed resolution probes also no longer log at ERROR, so one bad target no longer produces four ERROR lines before itsInvalidConfigError. - Config parsing:
restore_eventaccepted an unknownkindas user traffic and a non-objectpayloadas-is, and raised a bareTypeError/ValueErrorfor a system payload holding anevent_typekey or a non-numeric timer timestamp. All of these are nowSnapshotCorruptErroror restore correctly. - Runtime utilities:
Clock.pump()/SimulatedClock.increment()dropped every other due timer when one callback raised, and fired a timer that an earlier callback in the same batch had just cleared. Each timer is now re-checked before it runs, an error no longer loses the rest of the batch (the first error is re-raised afterwards), andincrement/set/fire_until/set_timeoutreject NaN and infinity. Both engines. - Runtime utilities: the pure transition API (
get_next_snapshot,transition, …) kept every machine alive forever through its probe cache; machines are now garbage collected.transition()also no longer revives a done/error snapshot: it is returned unchanged with no actions. - Runtime utilities:
wait_for_syncspun until timeout (or forever withtimeout=None) on a stopped or finished machine; both waiters now raiseWaitTerminatedError(aTimeoutErrorsubclass with.status), andwait_forno longer evaluates the predicate twice per poll.to_promiseraisesXStateMachineErrorfor an error status with no recorded error. - Runtime utilities: most library exceptions with structured constructor
arguments (
UnhandledEventError,SnapshotVersionError,ConflictError,ReceiptError, …) could not be pickled, which brokemultiprocessing,concurrent.futuresand Celery; they now round-trip. - Runtime utilities:
redact()/LoggingInspectorcrashed on namedtuples and self-referencing contexts, and over-redacted ordinary keys such asshipping,company,author,spinner(short entries likepin,pan,otp,card,authnow match whole words of the key). Exception reprs anddoneoutput in the inspector now pass through the redactor; free-text messages and tracebacks remain unredacted (documented). - Runtime utilities:
TaskManager.cancel_by_ownercould orphan a task added during its await and never pruned empty owner entries; the async engine is affected. - Runtime utilities:
from_iteratorlogged a false ‘blocked inside next()’ warning for healthy streams on the async engine, andsend_backto a stopped interpreter was lost silently on some paths; it now logs at DEBUG and fireson_event_dropped(..., "stopped"). - Runtime utilities:
logic_names(config)andlogic_names(machine)disagreed for composite guards, andspawn_<key>was reported as an action; both forms now agree and report spawn keys as services. - Pythonic DSL: a plain (undecorated)
async defmethod bound by name as a guard is now refused withNotSupportedErrorwhen the machine is built. Before, the un-awaited coroutine was truthy, so the guard let every transition through, with only a RuntimeWarning (class-basedStateMachine, both engines). - Pythonic DSL:
engine="auto"now selects the asyncio engine when a plainasync defaction or service is bound by name (actions=["do_it"]). Before, only decorated callbacks were counted, the sync engine was chosen, and the action never ran. - Pythonic DSL: a decorated callback named like a facade member (
@action def send,can,context, …) or like a lifecycle method (start,stop) now raisesInvalidConfigErrorwhen the class is defined. Before, it silently replaced the facade method, sosm.send("GO")ran the user’s action. - Pythonic DSL: a state named
startorstopno longer breakssm.start()/sm.stop(). The state stays in the chart, the lifecycle methods keep their meaning, and a warning is logged, as for transitions of those names. Before,sm.stop()raisedTypeError: 'BoundState' object is not callable. - Pythonic DSL: event names inferred from transition attributes keep
SCREAMING_SNAKE names and acronyms intact:
START_NOW = a.to(b)isSTART_NOW(wasS_T_A_R_T__N_O_W), andHTTPErrorisHTTP_ERROR. - Pythonic DSL:
internal=Truecombined with a target now raisesInvalidConfigErrorinState.to/transition()/MachineBuilder.transition. Before, the target was dropped silently.transition()now acceptstarget=Nonefor internal transitions. - Pythonic DSL:
before_<event>/after_<event>hooks now run for events declared only inState(on={...})dicts. Before, they were reported as ‘will never run’ and indeed never ran. Anon_<event>hook for such an event now warns that it cannot attach. - Pythonic DSL: a nested state class that inherits states or transitions
from another nested state class now raises
InvalidConfigError. Before, it silently compiled to an empty atomic state. - Pythonic DSL: the functional API (
build_machine) now raisesInvalidConfigErrorfor a callable decorated as one kind but passed as another (a@guardinactions=[...]), for two callables registered under the same name (also onStateMachineclasses), and for a nameless callable such as afunctools.partial. Apartialwith an explicit_xsm_nameis now accepted instead of raisingAttributeError. Before, these cases were silently mis-filed or overwritten. - Pythonic DSL:
@action/@guard/@servicereject non-string or empty names, and refuse to re-mark a function as a different kind.@action()with no name now uses the function name instead of registeringNone. - Pythonic DSL: a transition source or target
Statethat is not part of the tree but shares its name with several states in it now raisesInvalidConfigErrorlisting the candidates. Before, it bound to whichever state was registered last. - Pythonic DSL:
MachineBuilder.state(always=...)no longer adds a""key to the caller’sondict.history=combined withfinal/parallel, or a history value other thanshallow/deep, now raises.child_states(initial=..., parallel=True)raises instead of droppinginitial, a secondchild_statescall merges its children instead of replacing them, androot()refusesid/states/initial. - Pythonic DSL:
StateMachine(state="b")no longer logs the misleading “Lookup will fail” warning for a relative path. - Persistence:
SQLiteStore.load()read a record and its durable deadlines in two separate statements, so a concurrent save could return a snapshot with another version’s deadlines (timers firing twice or lost). Both now come from one statement. Both engines. - Persistence:
apersisted()silently skipped theacquire()of any custom lock strategy (only the fence ran). It now raisesNotSupportedErrorfor strategies other thanOptimisticLock,PessimisticLockandNoLock. - Persistence:
IdempotencyPluginadmitted an event with no deduplication when the key function, principal callable orinstance_keyraised; it now refuses the event. - Persistence:
IdempotencyPlugin.flush_marks()stopped at the first failing inbox write and left later claims in flight; it now attempts every mark and re-raises the first error. - Persistence: a snapshot whose
pending_events/deferredlane was not a list raised a bareTypeErrorfromfrom_snapshot; it now raisesSnapshotCorruptError. A fractional snapshotversion(1.9) is rejected instead of truncated. - Persistence:
Deadline.remaining_ms()raisedOverflowErrorfor a huge finitedue_at_wall; it now clamps. - Persistence:
FileStoreleaked bareOSErrorfrom temp-file creation, delete, listing, lock-file open and directory creation (now store errors that remainOSErrorsubclasses where they were before);delete()/forget()take the key lock so a concurrent save cannot resurrect a deleted record;list_keys()no longer advertises filesload()would reject; saves fsync the directory on POSIX. - Persistence:
MemoryStore.forget()discarded a lock that was still held, letting a second holder in. - Persistence:
SQLiteStoreaccepted anyjournal_mode(now WAL or DELETE) and anybusy_timeout(now finite and >= 0); file-creation errors are store errors;close()closes every thread’s connection. - Persistence:
SQLiteInboxandSQLiteLogleaked rawsqlite3errors; they are nowStoreError. - Persistence:
persisted()/apersisted()exhausted a generator passed asplugins(marks never flushed) and did not clean up whenstart()raised. - Persistence:
load_interpreterignored restore options on a new key; unknown options now raiseTypeErrorandexpected_machine_hashis checked against the machine.check_identityno longer ignoresexpected_machine_hashwhenverify_machine_hash=False. - Persistence:
DueTimerScanner.last_resultraisedAttributeErrorbefore the first scan, and an invalidlock=(e.g. the class) was only reported on every tick; both now fail or initialise at construction.run_forever(0)is rejected. - Persistence:
TransitionLogPluginkept references to events the engine dropped;JSONLinesLog.appendno longer re-validates a stale sequence cache. - EDA & inspector:
replay_dead_letterno longer replays a dead letter whose stored data was redacted at capture (it would have sent"***"to the machine and marked the record resolved); it raisesReplayRefusedErrorunlessforce=True. Both engines. - EDA & inspector:
SseSink.close()no longer hangs on a stalled client with a full queue, andSseSink.send()encodes a message before recording it, so an unencodable message leaves no half-recorded history entry. - EDA & inspector:
SseSink('::1')now binds an IPv6 loopback address instead of failing with a name-resolution error. - EDA & inspector: one corrupt row in the SQLite outbox no longer stalls every later row; it is logged and parked, and the relay carries on.
- EDA & inspector:
OutboxRelay.relay_onceand the broker helpers now await any awaitable (Future, Task) returned bypublish, instead of marking the row sent for a lost publish. - EDA & inspector: a state-store outage raised inside a machine run is
requeued without counting an attempt, rather than dead-lettered as poison
after
max_attempts; a down dead-letter store no longer double-counts the attempt. - EDA & inspector:
Envelope.to_jsonrefuses NaN/Infinity (EnvelopeCorruptError) instead of writing JSON that every consumer rejects. - EDA & inspector:
OutboxPluginvalidatesmeta.publishwhen the interpreter starts and logs a warning when a requesteddatafield is missing from the context;drain()andBrokerDeadLetterSink.flush()await every pending publish and log each failure. - EDA & inspector:
InspectorPluginno longer fails on a non-dict context;read_jsonlreports an absurdly deep recording line asValueErrorinstead of crashing withRecursionError;OutboxRelayrejectsbatch < 1. - CLI:
xsm generate-template(typed, pytest, class-json, function-json and every pythonic template) no longer writes machine-supplied event, action, guard, service or actor names into docstrings and comments unescaped. A hostile machine JSON could inject top-level code into the generated module. These strings now go through the same sanitiser as machine ids. - CLI: the generated runner is now syntax-checked before anything is
written, and
escape_for_stringescapes newlines, carriage returns and Unicode line separators. An event name containing a line break used to produce a runner that raised SyntaxError while the CLI exited 0; it is now refused with exit 1. - CLI:
xsm docs -o DIRandxsm diagram -o DIRno longer build the output file name from the raw machine id. An id such as../escapedcould write outside DIR. Names are sanitised, and a failed write is reported instead of a traceback. - CLI: function-json and class-json logic stubs no longer shadow names
the generated module itself binds. A service named
time,loggingorlogger, or an action namedEvent, used to replace the import and break at run time. Such names now get a numeric suffix. - CLI:
--json-childwithout a parent machine is now rejected, and--json-parentgiven twice (including--json-parent=a --json-parent=b) is a usage error.--sleep-timerejects negative values. - CLI: when the output files already exist and stdin is closed (CI),
xsm generate-templatenow exits with ‘pass –force’ instead of an EOFError traceback. A closed stdin is never treated as consent to overwrite. - CLI: the generated class-json and function-json hierarchy runners
(
-jp/-jc) now bind the same logic to the child machines. Children with actions, guards or services previously raised ImplementationMissingError. The pythonic hierarchy runners are not yet fixed. - CLI: post-processing no longer strips
from __future__ import annotationsfrom generated modules. - CLI: transitions with a list-valued
target(multi-target) are now reported by name as unsupported by the code generator, instead of surfacing as an unexplained ‘on differs’ refusal. - CLI:
xsm validateand the other commands built on the shared analyser report an unreadable or non-UTF-8 file, and an unexpected machine shape, as findings instead of a Python traceback. - CLI: in the interactive simulator, the
tkey no longer crashes on machines whose timers use named delays, andcrejects nan, inf and negative values. - CLI: the interactive launcher now shows the message of a failed
command, and
simulate/pytest-scaffold generation restore the previous logging state instead of re-enabling all logging. - CLI:
xsm update --jsonprints a single JSON document, and version parsing handlesrc+.devand.postreleases. - CLI:
xsm gt --check/--diff --from-django-fsmno longer writes the extracted chart into the output directory. - Web adapters: a StatechartRegistry resident whose chart reached a final state was never saved when it was evicted, released or shut down; the completing transition was lost. It is now persisted (async engine).
- Web adapters: with create_if_missing=False, the FastAPI router and Litestar controller answered 404 for a missing key and 403 for an existing one to an unauthorised caller, which let anyone enumerate instance keys. Authorization now runs first, so both answer 403 (Starlette registry send_event).
- Web adapters: Flask and Quart accepted a malformed or over-long Idempotency-Key header and silently ignored the key when no inbox was configured. They now answer 400 and 501, as the Starlette stack does.
- Web adapters: a client-supplied idempotency_key in the JSON body (HTTP and WebSocket, all adapters) bypassed the header validation. It is now refused with 422; the key comes only from the Idempotency-Key header.
- Web adapters: a GET on an unknown key through Flask or Quart started a real interpreter and ran the initial state’s entry actions. It now reports the initial state without running anything.
- Web adapters: Quart GET /history returned raw event payloads and ignored after/limit. It now omits payloads and validates the bounds like Flask (negative values are 400 on both).
- Web adapters: Litestar get_interpreter hung a sibling dependency for the same key when an anonymous caller’s principal check failed. The request now answers 401 (Litestar).
- Web adapters: a deeply nested JSON body or WebSocket frame raised RecursionError and produced a 500 (or tore down the WebSocket). It is now a 422 or an error frame (Starlette, FastAPI, Litestar, Flask, Quart).
- Web adapters: Quart 5xx problem responses were not logged, so operators had no traceback. They are logged like the Flask ones.
- Web adapters: Quart SSE parked one default-executor thread per connected client for each heartbeat interval; it now waits on an asyncio event.
- Web adapters: Quart body reads were unbounded for chunked requests and had no timeout. Over-limit is now 413 and a stalled body is 408; Litestar routes now also honour body_timeout_s (408).
- Web adapters: the Flask SSE fan-out kept sequence counters for every key ever used and silently dropped events for a slow client. It now keeps state only while someone listens and cuts a lagging client off so it reconnects to a fresh snapshot.
- Web adapters: SSE and WebSocket streams read the snapshot before subscribing, so a transition committed in between was lost. They now subscribe first (a transition may appear in both the snapshot and the stream, never in neither).
- Web adapters: Flask GET /
/history with a negative after or limit answered 500; it is now 400. - Web adapters: an event sent to a finished instance through Flask or Quart answered 500 (now 409), and an oversized snapshot through the Starlette stack answered 500 (now 413).
- Web adapters: numeric registry options (scanner_interval_s, drain_timeout_s, resident_idle_ttl_s, settle_timeout, max_body_bytes; Flask heartbeat_s and max_body_bytes) are validated at construction instead of busy-spinning or refusing every request.
- Web adapters: Flask and Quart act() called from your own view gave a 500 for an anonymous caller; it is now 401.
- Web adapters: the Origin check compared host names case-sensitively and had two diverging copies; there is one shared rule now.
- Web adapters: the flask xsm CLI turned sys.exit() into exit code 1 and dropped sys.exit(‘message’); it now follows Python’s convention.
- Web adapters: events whose names are unsafe in a URL path (a slash,
braces,
?,#,%, a tab or a line break; a plain space is fine and is served percent-encoded) now fail the FastAPI router and Litestar controller build with a clear ValueError instead of producing an unreachable route. - Web adapters: instrument_app deduplicates by registry identity and mounts the health and ready probes ahead of any catch-all mount; unexpected 5xx in the generated routes are now logged with a traceback.
- Django & SQLAlchemy: a Django model whose
statechart_machine/statechart_logicwas a dotted string naming a callable that takes the row was resolved once withrow=Noneand cached for the life of the process; it is now resolved per row (zero-argument factories stay cached). Django. - Django & SQLAlchemy:
StatechartModelMixinwith a prebuiltMachineNodeplus module/providerstatechart_logicsilently dropped the logic; it is now rebound by rebuilding from the node’s source config. The SQLAlchemy mixin now raises TypeError when__xsm_machine__/__xsm_logic__do not yield a MachineNode / MachineLogic. - Django & SQLAlchemy: the DRF
history/route returned HTTP 500 for non-integer user primary keys (UUID/str); the actor is now rendered as a string (integers unchanged). - Django & SQLAlchemy: unexpected exceptions in the DRF send route and the Channels consumer returned a class-name-only 500 but were never logged; the full traceback is now logged server-side.
- Django & SQLAlchemy:
DjangoStoreclassified any message containing ‘locked’ or ‘busy’ as lock contention (a missing table namedlocked_orderslooked retryable); it now uses the anchored classifier already used bysend(). - Django & SQLAlchemy:
AsyncSQLAlchemyStore.save/load/delete/forgetleaked raw driver errors on lock contention and, withcreate_tables=False, never verified the schema; both now matchSQLAlchemyStore(LockTimeoutError; missing/newer schema refused). - Django & SQLAlchemy:
FSMDualWriteMixinwrote the legacy FSM column from the receipt, which missed engine steps of the same send (done.state, eventless chains); it now uses the written snapshot. - Django & SQLAlchemy: a wide parallel chart overflowing the
<name>_statecolumn now raises a clear ValidationError namingstate_max_lengthon every backend (no migration change). - Django & SQLAlchemy: the Django admin
render_change_formreadobjfrom the wrong positional index; a bulk action no longer aborts the batch on one row’s unexpected error (the row counts as failed, is logged, and error receipts are no longer counted as denied). - Django & SQLAlchemy:
SQLAlchemyMixin.sendnow acceptspayload=like the Django mixin, and exportsRESERVED_PAYLOAD_KEYS/reserved_keysfor web adapters. - Django & SQLAlchemy: concurrent first saves in
DjangoStoreand the SQLAlchemy store could raise a raw IntegrityError; the loser now retries as an UPDATE. - Django & SQLAlchemy:
xsm_deadlines --forevernever refreshed stale DB connections and exited on the first transient DatabaseError; it now recycles connections and retries after logging. - Django & SQLAlchemy: SQLAlchemy
ModelStore.forget()left the snapshot (and any personal data in context) in the business row; it now nulls the snapshot and sibling columns and bumps the version, as the Django model store does. Malformed keys in both model stores now mean ‘not found’ instead of ValueError / ValidationError. - Django & SQLAlchemy: the Channels consumer survived a deleted row (404
problem / close 1008) instead of dropping the socket, refuses inbound
frames over
max_frame_bytes(64 KiB), and no longer swallows a cancellation ofdisconnectitself. - Messaging & agents:
RedisStore.list_keys(sync and async) advertised keys whose snapshot had expired throughttl_s, and the key index grew without bound; expired members are now pruned and no longer listed. - Messaging & agents: the RabbitMQ broker stopped consuming for good after a failed ack (for example a stale delivery tag after a reconnect), because its un-acked counter never recovered; the counter is now released even when the ack raises.
- Messaging & agents: a broker
ack()ornack(requeue=False)that failed in the transport left the delivery un-retriable; it can now be retried. Redis Streams also no longer hides an entry from its own consumer after a failed XACK. Sync and async brokers. - Messaging & agents:
PydanticCodecrejected contexts carrying library-private_xsm_*keys when the model usedextra="forbid", skipping typed normalisation on every save. - Messaging & agents: the pydantic config gate accepted lax values such
as “false” for a transition’s
internal/reenter, which the engine reads withbool()as the opposite; these flags are now strict booleans. - Agents: a NaN/inf/garbage usage block made
ModelResponse.to_dictraise and the turn was retried; it now fails closed on the budget. The globalBudgetPluginlikewise counted invalid child usage as zero and now treats it as exhausting the budget. - Agents: tools taking
datetime,Decimal,UUIDor enum parameters could never be called from JSON arguments because validation was strict python-mode; arguments are now validated as JSON. Synchronous tools under the async engine now run on a daemon thread so a hung tool cannot block shutdown. - Messaging & agents:
RedisStoreerrors no longer includestr(exc)for connection-class failures (host:port, possible credentials), only the exception class name; a server reply (ResponseError) keeps its text, minus any URL and any echoed command arguments, so the cause stays readable; aprefixmade only of colons is rejected.
[0.11.0] - 2026-10-13
Added
- Added an in-page Fullscreen viewer to every embedded Stately diagram. Large charts use the documentation viewport without opening the editor; the responsive toolbar keeps Close and Open in Stately available. The viewer follows the site theme, restores page position and keyboard focus, and unloads its separate iframe when closed. Inline charts and machine definitions are unchanged.
Changed
-
Aligned the development lockfile with version 0.11.0, its declared extras, and development dependency groups. Clarified the release checklist and publishing workflow documentation without changing the publishing gates.
-
Made the GitHub Pages documentation responsive across phones, tablets and desktops, with accessible mobile menus, persistent search/theme controls, and contained code/table scrolling. Expanded statechart JSON and Python dictionaries throughout the guides, API reference and homepage without changing their behavior, and added formatting/layout regression checks. Diagram fullscreen controls now fit small screens, preserve usable zoom and keyboard focus, and follow the selected site theme.
-
Redesigned the README with responsive SVG branding, clearer onboarding and navigation, an expanded event-driven architecture introduction, and scoped competitor comparisons. Preserved the technical guide, examples, diagrams and API reference while clarifying operational guarantees and limitations.
Removed
- Removed the obsolete
VIDEO_DIAGRAMS.mdandVIDEO_SCRIPT.mdproduction drafts, the standalone.release-notes-0.8.0.mddraft, and the unused root-levele2e.pyscratch script that depended on a missingmc.json. Use the maintained user guide and runnableexamples/instead of the video drafts and scratch runner; use the versioned changelog below for release history. Regression evidence and maintained tooling remain available.
Fixed
-
Gave the 16-worker SQLite audit-log stress tests an explicit contention budget while preserving their 1,600-write workload, error reporting, commit counts, and gap-free sequence assertions. Production lock-wait defaults are unchanged; regressions retain timeout propagation even when a snapshot has committed before its separate audit-log append.
-
Replaced the documentation footer’s outdated test count and Python range with a link to the maintained compatibility guide.
-
Preserved leaf-state containment and machine-level entry arrows in Mermaid exports, including non-initial and unconnected states. Regenerated the documentation examples and documented the existing local-key alias limitation. Corrected deep-link diagram containment and separated actor, retry, and slot-filling transition labels without changing interpreter behavior or the represented events, guards, and actions.
-
Corrected the Guards and Reliability guide diagrams so decision diamonds remain connected and display their question labels. Preserved every condition and branch, with source regression checks and visual verification in the site’s Mermaid renderer.
-
Made SQLite store scaling checks independent of short disk/WAL timing samples that could fail on shared Windows runners. The original 1, 100, and 2,000-record workloads and timing reports remain, with fixed SQL instruction budgets enforcing bounded save/load/delete and limited-list work. Regressions reject missing deadline indexes and unbounded listings, preserve operation results, and verify instrumentation cleanup. Runtime behavior and the 90% coverage minimum are unchanged.
-
Made the 100,000-record deadline-scanner regression independent of CI runner speed by checking bounded metadata/SQL work instead of a fixed half-second duration. Preserved exact due-key ordering and zero snapshot loads, with regressions for excessive work and probe cleanup. Runtime behavior, CI workflows and the 90% coverage minimum remain unchanged.
-
Stabilized the Litestar streaming regression under coverage by allowing valid SSE heartbeat comments before transitions. Deterministic tests keep strict event ordering, bounded waits, slow-subscriber cutoff and connection cleanup checks without changing runtime behavior or the coverage gate.
-
Eliminated a timing-dependent false positive in the EDA example telemetry privacy checks by checking metric labels separately from numeric samples. Python 3.9 adoption failures now include complete nested test diagnostics.
Release polish (Changed)
- Python 3.15 is supported and tested (classifier, the full Linux matrix cell and the Windows / macOS spot-checks). The only API the interpreter still used that 3.14+ turns into an error without a set loop –
asyncio.get_event_loop()insidewait_for– isget_running_loop(). - Type safety: the library is
mypy --strictclean (496 findings across 104 files resolved: every generic parametrised, every def annotated, 34 stale# type: ignoreremoved; the ones that remain all carry a code and a reason for a framework base with no stubs).[tool.mypy] strict = trueis now the configured gate, enforced by the CI lint job andtests/test_type_safety.py.pythonic.State(after=...)acceptsintdelay keys, as the generated builder template always has. - Docs: live, theme-following Stately editor diagrams for every machine on the site, including an Example Gallery page; the duplicate page
<h1>removed from 54 pages; every machine config in the guides, the README, the example apps and thexsm newtemplates re-flowed to one key per line / canonical 2-space JSON; the plugin-hook table listson_event_refused,on_event_sentandon_resolve_error; the Django guide lists the app’s six models;snapshots.mdno longer saysdeadlinesis unused. - Performance: build-time and per-event fast paths (#307 follow-up).
create_machine()no longer sorts keys, filtersx-and importsdifflibfor every state and transition whose keys are all known (one set comparison decides), skips label formatting for targetless transitions in the target validator, and does one thread-local lookup per node (not three per child) for the aliased-cycle guard; tag/meta validation runs only when declared. On the event path_collect_eligible_transitionsdecides the event’s kind once per call instead of once per ancestor and inlines the exact-keyonlookup for nodes without partial descriptors or a wildcard;_select_transitionshas a single-active-leaf fast path and takes the first (deepest) eligible transition instead ofmax();_execute_transitionno longer sorts a single exiting state or snapshots actor ids when there are no actors. Construction is ~20 % cheaper and a flatsend()~15 %; semantics are unchanged (same resolver, same descriptor matching, same ordering). The competitor table inbenchmarks/competitors/README.md, the README and the docs home page is re-measured (2026-10-11, sismic 1.6.14): construction and 1,000 instances, previously a tie withtransitions, are now 5-14 % ahead.
Release polish (Fixed)
- Plugin discovery (#296 battle A):
attach_discovered()no longer calls arbitrary loaded callables: onlyPluginBasesubclasses/instances, or a factory explicitly marked with the newplugin_discovery.plugin_factorydecorator (behaviour change for undecorated factory entry points, which are now skipped/refused);allow=matching is PEP 503 normalised;XSM_DISABLE_PLUGIN_DISCOVERYacceptsTRUE/onin any case;last_skipped/last_failedare published atomically per call (thread/re-entrancy safe); the Python 3.9 distribution lookup is built once perdiscover()instead of per entry point.
Brokers & Celery (Phase F) – #294, #292
- Broker adapters (#294),
xstate_statemachine.contrib.brokers.RedisStreamsBroker/SyncRedisStreamsBroker([redis]: consumer groups,XACK,XAUTOCLAIMof a dead consumer’s pending entries aftermin_idle_ms, optional subject-hashed shards),KafkaBroker([kafka], aiokafka: key = subject, commits only the contiguous acked prefix),RabbitMQBroker([rabbitmq], aio-pika: durable queues, persistent messages, optional consistent-hash exchange routed on subject),NatsBroker([nats], JetStream:<topic>.<subject>,Nats-Msg-Iddedup) andSqsBroker/SyncSqsBroker([sqs], boto3: FIFOMessageGroupId= subject,MessageDeduplicationId= envelope id,extend_visibility). One shared core gives every adapter local requeue-to-head, settle-once, broker redelivery counts stamped as envelope attempts (so poison reaches the DLQ across restarts, X0.8 – except on Kafka, which keeps no delivery count), the size cap before parsing (X0.4), undecodable messages dropped never looped, andhealthy/on_disconnect/on_reconnect. Every adapter passesAsyncBrokerContractin CI (fakeredis, moto, in-memory client stand-ins) and, opt-in (XSM_CONTAINERS=1, manuallive-brokersCI job), on real brokers in testcontainers with 1,000 envelopes / 10 subjects and a broker restart mid-consume. Registered under thexstate_statemachine.brokersentry-point group (xsm plugins). Pins:aiokafka>=0.10,aio-pika>=9,nats-py>=2,boto3>=1.28;[eda]umbrella filled. Guide: Brokers. [celery](#292),xstate_statemachine.contrib.celery.celery_service(task, *, args_from, timeout_s, queue)makes a Celery task aninvokeservice:onDonefrom the result,onErroron failure or timeout,revoke()on state exit (best effort). Completion is delivered through the engine’s own actor-logic path (a forgeddone.invokeis still refused): live through a result-backend watcher, durably throughdeliver_resultfromtask_success/task_failuresignal handlers (connect_signals) orpoll_results. Task headers are trusted only after the persisted instance confirms the invocation is still active with that task id; stale completions are ignored and reported toon_event_dropped.@statechart_task(app, store, machine)runs thepersisted()act-loop on a worker and retriesConflictErrorthroughautoretry_for; it refuses a non-JSON / pickle-accepting app.poll_resultsis the durable delivery path; a signal-path completion that beats the caller’s save is parked (MemoryPendingResults) and retried there, never dropped. Every entry point refuses pickle / YAML (by name or MIME type) for task and result deserialisation (assert_json_serializer).DurableTimerSchedulerrunsDueTimerScanner.run_oncefrom Celery Beat (xsm_deadlines_every) plusetajobs that carry the state-entry generation (X0.9);outbox_relay_taskdrains the outbox. Pin:celery>=5.3. Guide: Celery.
Django, DRF & Channels (Phase D) – #280, #281, #282, #283, #310
[django]: a real statechart on a Django model (#280).StatechartFieldstores the snapshot as JSON. Sibling columns (<name>_stateindexed,_state_ids,_version,_machine_version) are added incontribute_to_class, somakemigrationsworks normally and a second run is clean.filter(statechart__state=...),__state__inandobjects.in_state("order.review")(whole-segment match, bound parameters) are indexed queries. The snapshot is size-capped on write and read (X0.4).StatechartModelMixin.send()runs intransaction.atomic()withselect_for_update()by default (on SQLite a write lock first).lock="optimistic"fences on the version column and raisesConflictError(send_with_retry). Sixteen threads x 100 sends is exactly 1600 in both modes. A writer that waits out the database’s busy timeout (SQLitedatabase is locked, Postgreslock timeout) raises the retryableLockTimeoutError–send_with_retryretries it – never a bare driverOperationalError. Also added:can/available_events/machine/matches,asendfor async views,forget_statechart()(X0.5), andsave()that never rolls the state back.DjangoStorepasses the A2StateStorecontract suite. Persistedafterdeadlines live in an indexed table thatmanage.py xsm_deadlines(DueTimerScanneroverDjangoModelStore) fires. New:refresh_statechart_columnsand therefresh_statechart_columns_opmigration helper.- Signals, audit and permissions (#281).
pre_transition(raiseTransitionVetoed→denied, no change, no audit row),post_transition(inside the transaction: a raising receiver rolls back the state and its audit row;connect(..., on_commit=True)defers totransaction.on_commit), andstatechart_error.TransitionLogrows are written in the same transaction as the state change, with a redacted payload andactor/reason.forgetredacts by default.PermissionGuard(object-levelhas_perm),RoleGuard,AnyOf,AllOf.has_event_permission(user, obj, event)answers “may this user do it” once for the admin, DRF and Channels.DjangoOutboxStore(EDAOutboxStore) rows join the openatomic(), so a rollback leaves no row. - Admin and management commands (#282).
StatechartAdminMixinrenders one button per event this user may send. Each button is a CSRF-protected POST form, and the permission is re-checked on POST. A GET changes nothing.meta.confirmevents get a confirmation page with a reason that is stored in the audit row. Also added: a history inline, a state filter, bulk actions (n changed / m denied) and a Mermaid diagram view.manage.py xsm_inspect / xsm_diagram / xsm_docs / xsm_simulatematchxsmbyte for byte. New:xsm_snapshots --stale. [drf]and[channels](#283).StatechartViewSetMixingenerates one@actionper declared event plussend/,events/,history/(the view’s pagination and filters, with a separate permission) andstream/. The receipt maps to 200/202/409/422 through the corereceiptstable, and a permission-guard refusal is 403. A viewset without explicitpermission_classesdoes not build (closed by default, X0.1).Idempotency-Keyis scoped torequest.userthrough the newDjangoInbox(X0.2). New:StatechartSerializerField(the FastAPIGET /{id}shape, shared golden fixture) and a drf-spectacular schema with typed 403/409/422 responses, validated in CI.StatechartConsumerrequiresAuthMiddlewareStack(close 1008 otherwise), sends a snapshot on connect, broadcasts each transition to every connection on the row, and has a heartbeat. 100 connect/disconnect cycles leave nothing behind.- Migrating from django-fsm-2 (#310).
manage.py xsm_migrate_fsm app.Model --field stateextracts XState JSON from the@transitiondecorators (--dry-run/--write-chart; conditions and permissions become guard names). It then fills snapshots from the FSM column, batched, resumable and idempotent.FSMDualWriteMixinkeeps the old column in sync for the dual-read release. New core primitive:persistence.from_state_ids(machine, ids, context)builds a snapshot for a configuration without running anything, and loudly refuses illegal configurations. - Packaging. The
[django],[drf]and[channels]extras now pindjango>=4.2,djangorestframework>=3.14andchannels>=4, and join[all]. The Django classifiers are added. The three CI cells install pytest-django (+ drf-spectacular / daphne / django-fsm-2). The oldest proven set on Python 3.9 is Django 4.2, DRF 3.14 and Channels 4.0. - Docs. New pages: Django and
DRF & Channels. The
vs django-fsm migration recipe now
describes the shipped command. New example:
examples/integrations/django_approvals.Event-driven architecture (Phase F core) – #272, #293, #295
xstate_statemachine.eda(#272, #293). A zero-dependency core package (not loaded byimport xstate_statemachine).Envelopeis a frozen CloudEvents 1.0 event with thecorrelationid/causationid/machineid/machineversionextensions;subjectis the instance key and the partition key; ids are sortable UUIDv7-style values. It is validated beforeto_event()(EnvelopeCorruptError), size-capped before parsing (EnvelopeTooLargeError, X0.4), refuses credential-bearing extensions and validatestraceparent(X0.8).BrokerAdapter/SyncBrokerAdapterprotocols (#272) with a contract suite (tests/eda/contract.py) the #294 adapters will run, and in-memoryFakeBrokerAdapter/SyncFakeBrokerAdapter(failure injection,deliver(),drain()), also importable fromxstate_statemachine.contrib.testingnext toreplay()and the newassert_replay_consistent().InboundDispatcher(#293): envelope →persisted(key=subject)→send(envelope.to_event()), with inbox dedup on the envelope id, per-subject ordering withmax_in_flightconcurrency, and poison handling (X0.8): a failed delivery is not committed and is requeued; atmax_attemptsit is dead-lettered and acked. Unknown types and corrupt envelopes are dead-lettered, never raised into the loop.- Transactional outbox (#293, #284 part 3).
OutboxPluginpublishes what the chart declares:meta.publishon a transition (now parsed asTransitionDefinition.meta) or apublish-tagged state. Its sinks are anOutboxStore(zero-depSQLiteOutboxStore, orSQLAlchemyOutboxStorein[sqlalchemy]) that commits with the snapshot underPessimisticLockand is drained byOutboxRelay, or a broker directly.causationidis the inbound envelope that caused the transition. - Dead letters (#293).
patterns.DeadLetter/DeadLetterPluginare extended, not forked. Records gainid,reason,envelope,topic,machine_hash,machine_versionandresolved_at. Stores gainput,get,list,mark_resolvedanddelete. New:SQLiteDeadLetterStore(redacted, audited) andBrokerDeadLetterSink(publishes to<topic>.dlq). The plugin also accepts any object withput(). xsm dlq list|show|replay|purge(#293).replayis a dry run unless--no-dry-run --yes --reason, reuses the envelope id (the inbox dedups a double replay), refuses a changed machine without--force, and writes an audit row, as doespurge.- AsyncAPI (#293, #295).
asyncapi_document(machine)renders consumed and published events as an AsyncAPI 3.0.0 document with CloudEvents messages, validated offline against a vendored schema (validate_asyncapi). Newxsm asyncapi machine.json [-o] [--validate];xsm docsgains an “Integration events” section. patterns.SagaBuilder(#295) emits plain,strictConfig-clean chart JSON for an orchestrated saga: per-step invoke, timeout and optionalRetryPolicy, reverse compensation exactly once, andcompensationFaileddead-lettered. Every step transition is published.patterns.ChoreographyRouterroutestype → (machine, EVENT)overInboundDispatcher, and the causation chain ties the conversation together.[cloudevents]extra (#293) (cloudevents>=1.10, 1.x and 2.x):to_cloudevent/from_cloudeventand HTTPto_binary/to_structured/from_http, which reads onlyce-*headers and drops credential-bearing ones. Added to[all], the compat matrix and the CI extras cell.- Docs: new Event-driven architecture page; the Guarantees page’s outbox and ack steps are now shipped.
- Also in
xstate_statemachine.eda: theOutboxRecordrow type and in-memoryMemoryOutboxStore, theDispatchResultreturned by the dispatcher,BrokerPublishError,ReplayRefusedError/ReplayResult/replay_dead_letter,redact_record,load_asyncapi_schema, and the constantsPUBLISH_TAG,ATTEMPT_EXTENSION,DEFAULT_MAX_ATTEMPTSandDEFAULT_MAX_IN_FLIGHT. - Example:
examples/integrations/eda_fulfilment. An order chart and a warehouse chart that talk only through events (ChoreographyRouter), with the SQLite outbox committed with the snapshot, inbox dedup, poison -> dead letters andxsm dlq, the Celery bridge in eager mode, and Prometheus / OpenTelemetry / inspector sinks in memory. The same demo runs on all five real broker adapters – Redis Streams, Kafka, RabbitMQ, NATS JetStream and SQS – offline over in-process stand-ins (fakeredis, fake aiokafka / aio-pika / nats-py clients, moto) or against a live server whenREDIS_URL/XSM_KAFKA_BOOTSTRAP/XSM_RABBITMQ_URL/XSM_NATS_URL/XSM_SQS_ENDPOINTis set, with a parametrised suite (order, outbox == published, poison -> DLQ, redelivery carries the attempt). It needs no running service:python -m eda_fulfilment --broker kafka. - Fixed:
[sqlalchemy]on SQLite: a newly created snapshot could survive a rollback of itsPessimisticLockblock (#293). pysqlite emits noBEGINbefore aSAVEPOINT, so when the create-only INSERT was the first write of the lease, its savepoint’sRELEASEcommitted. The inbox, log and outbox rows in the same block rolled back while the snapshot did not. The savepoint now nests inside an explicit transaction. - Fixed:
persisted()/apersisted()restorebuffer_markson exit and key post-save buffers by block (#293). A plugin shared by concurrent blocks (worker threads, interleavedapersistedtasks) can no longer flush another block’s rows, and one reused outsidepersisted()writes immediately again. Markers flush inbox-first and all run even if one fails.
Added
-
StoreUnavailableError(StoreErrorsubclass) and typed Redis errors (battle-test #306). A Redis failover surfaced as a rawredis.exceptions.ConnectionErrorpastexcept StoreError: every web route answered500 "ConnectionError"with a traceback per request. The[redis]store, inbox and log now mapredis.RedisErrorthe waySQLiteStorehas since #259 – connection-class failures areStoreUnavailableError, which the Starlette/FastAPI, Flask and Django adapters answer with 503 Store unavailable (one WARNING line per request, no exception text;/_xsm/healthstays 200,/_xsm/readyis 503).IdempotencyPlugin(on_inbox_error="refuse")refuses on it. ClientsRedisStore,RedisInboxandRedisLogbuild from a URL getDEFAULT_SOCKET_TIMEOUT_S/DEFAULT_SOCKET_CONNECT_TIMEOUT_S(5 s; URL query overrides), so a server that accepts TCP and never answers is a boundedStoreUnavailableError, not a hang (a client you pass in keeps its own settings).RedisStore.due_keys/AsyncRedisStore.due_keysread the sorted-set index for the scanner. -
StreamEvent,drain_pending_cleanups(timeout=),DEFAULT_CLEANUP_TIMEOUT(battle-test #267).StreamEventis theEventsubclass a stream actor (from_async_iterator/from_iterator) delivers:event.datais the item (theDoneEventprecedent and what the #267 verification script read),event.payloadstays{"data": item}so dict / JSON code keeps working. It round-trips throughpersist_event/restore_event(a"stream": trueflag in the record; a record without it is a plainEvent), so a pending stream item in a snapshot restores as the same shape on both engines.drain_pending_cleanups(timeout=30.0)bounds how long the asyncstop()waits forasync defcleanups: a cleanup still running after the timeout is cancelled and logged (Nonewaits without bound).StreamEvent,RunningLogic,drain_pending_cleanupsandDEFAULT_CLEANUP_TIMEOUTare exported from the top-level package. PRIVATE_CONTEXT_PREFIX("_xsm_"),is_private_context_key(),public_context()(battle-test #265). A reserved prefix for library bookkeeping that must survive the process and therefore rides in the snapshot’scontext.context_model(...)validates the user’s keys only (a model withextra="forbid"is unaffected); the Starlette / FastAPI / Litestar / Flask / Channels state bodies hand thecontext_serializerthe public view. The first such key is the dead-letter error chain,patterns.ERRORS_CONTEXT_KEY("_xsm_errors", now exported).DeadLetterPlugin(max_errors=20)– the chain keeps the newest N entries; messages are cut at 1 000 characters and secret context values are masked inside them.ScanResult.locked(#264 follow-through) and thecircuit_breaker_call_closedbenchmark row.-
examples/integrations/fastapi_ordersgateway-outage scenario – oneCircuitBreakerper process in front of the payment gateway (registry.breaker),paymentFailedtaggeddead-letter, aDeadLetterPluginon the registry writing into the orders SQLite file (registry.dead_letters, visible viaxsm dlq). 60 orders during an outage hit the gateway 3 times; 32 threads on a dark gateway are refused, not hung; half-open admits exactly one probe; a dead-lettered order recovers by paying again. SQLiteStore.due_keys(until_wall, *, limit=1000)andMemoryStore.due_keys(...)(battle-test #264).(key, earliest due_at_wall)for keys with a matured deadline, earliest first – one query on thedeadlines_dueindex (SQLite) or a dict walk (Memory), the shape the[sqlalchemy],[redis]and[django]stores already had.DueTimerScanneruses it when present: 100 000 records / 100 due went from 3.5 s and 100 000 record loads to 0.023 s and none.-
examples/integrations/fastapi_ordersscheduler-outage scenario – 400 orders arm timeouts and retry backoffs over an afternoon; the one scheduler is down for three hours. The backlog drains oldest-first inlimit=100batches,max_lag_sreports the outage, live timers are left alone, two schedulers started by accident still commit each deadline once (both locks), and a crash between fire and save re-fires without a torn record.build_scanner()accepts overrides. Interpreter.await_settled(timeout),apersisted(settle_timeout=),DEFAULT_SETTLE_TIMEOUT(battle-test #263).await send(..., wait=True)resolves at the end of the event’s macrostep; a service completion is the next macrostep, which the run loop starts at once. On a chart with two plaindefinvokes in a row (authorise → capture) the machine was therefore mid-step exactly when anapersisted()block exited, and the snapshot was refused (SnapshotMidStepError→ HTTP 500) – while the sync engine’ssend()drains the chain andpersisted()never saw it.await_settledwaits, bounded, until no step is in flight and no engine completion is queued or owed (plaindeffutures andasync deftasks); a liveaftertimer is not owed work.apersisted()and the Starlette / FastAPI / Litestar / Quart registries settle before the snapshot and before the receipt body, so the200a caller reads is what the store holds.settle_timeout=0restores the old loud refusal.BaseStore.list_versions(prefix=, limit=)andMAX_MACHINE_VERSION_LENGTH(255) (battle-test #263).(key, label)pairs without loading the blobs: oneSELECTon SQLite, a header read onFileStore, a dict walk onMemoryStore; third-party stores fall back tolist_keys+load. Whatxsm snapshots --staleuses.xsm snapshots --fail-if-stale– exit 1 when stale keys exist (implies--stale): the deploy gate between “v2 rolled out” and “v1 workers retired”. Exit 2 for bad input (not a machine, missing or unreadable store, a nesting bomb).--jsonreportstotalandtruncatedwhen--limitcut the list; the table title says “N of M”.-
examples/integrations/fastapi_ordersrolling-upgrade scenario –machine_v2.json(authorise → capture, acurrencykey) andmigrations.py(one scopedSnapshotMigratorstep); v1 orders at every interesting point are migrated lazily, once, by v2 workers and the v2 scheduler; 50 concurrentPAYs on one stale order commit exactly one migration;xsm snapshots --staleis the drain list before and after. -
IdempotencyPlugin(on_inbox_error="refuse" | "admit")andInboxUnavailableError(HTTP 503) (battle-test #261). What a keyed event gets when the inbox backend fails."refuse"is the default – the receipt carries the typed, retryable error and the action never runs;"admit"opts into availability (the event runs undeduplicated andon_plugin_errorfires) for webhooks whose sender retries anyway.receipt_to_statusmaps the new error to 503;STATUS_UNAVAILABLEexported fromreceipts. - Observability & live inspector (Phase B) –
[observability]extra (#273).OpenTelemetryPluginopens onestatechart.transitionspan per processed event and closes it inon_event_processedwith the outcome (statechart.from/.to/.changed/.denied/.deferred/.actions); guard evaluations are span events, action / guard / service errors and chain trips arerecord_exception, each invoked service is a child span, and atraceparentpayload header becomes a span link.PrometheusPluginexports transitions, event dispositions, guard evaluations and errors, action errors, service duration and errors, chain trips, active interpreters and a polledqueue_depth.StructlogPlugin/LoguruPluginbindmachine_id,state,event(andcorrelation_id) around each event so log lines inside actions carry them;SentryPluginadds breadcrumbs and, opt-in, captures errors.instrument_all()attaches them to every interpreter built afterwards (global registry), to one interpreter, or to a registry’spluginslist;discovered=Truegoes throughplugins.attach_discovered. X0.6 telemetry hygiene: event labels come from the chart allow-list (unknownfallback), every label dimension is capped (max_label_values->other), and payloads, instance keys and correlation ids are never labels. The extra pinsopentelemetry-api>=1.20andprometheus-client>=0.17; structlog, loguru and sentry-sdk are soft imports.AgentTracePlugin(on_span="otel")emits realgen_ai.*spans. Theplugins_*performance rows now includePrometheusPlugin. Guide: Integrations -> Observability. - Observability & live inspector (Phase B) – live inspector (#274).
xstate_statemachine.inspect(stdlib only) speaks the@statelyai/inspectwire protocol (@xstate.actor/.event/.snapshot), pinned against fixtures recorded from the real npm package.InspectorPlugin(sink, context_allowlist=...)withMemorySink,JsonLinesSink(files 0600),SseSink(SSE overhttp.server, own fallback page plus the hosted Stately UI) andreplay_messages(). CLI:xsm inspect machine.json --live [--port] [--open],xsm sim --record session.jsonl,xsm replay session.jsonl [--live]. New plugin hookon_event_sent(interpreter, target_id, event)fires on the sender ofsendTo/sendParent/forwardToon both engines.MachineNode.source_configkeeps the config the machine was built from. X0.7: per-runsecrets.token_urlsafe(32)compared withhmac.compare_digest, the token in the URL only on first load (then anHttpOnly; SameSite=Strictcookie), loopback bind withHostandOriginchecks, a non-loopback host requires--token, strict CSP, context deny-by-default.[starlette]/[fastapi]:mount_inspector(app, registry, debug=True)now serves the stream over WebSocket (WebSocketSink) instead of answering 501. Guide: Integrations -> Live inspector;xsmguide section “Live inspector”. [testing]: generated path tests (#269). Request thexsm_pathfixture under anxstate_machinemarker and the test is parametrised overgraph.shortest_paths(machine)– one case per reachable configuration, with ids likepath[editing->authenticating3DS->challenge].xsm_path.replay(xsm_interp, xsm_clock)drives the interpreter there.--xsm-full-pathsswitches tosimple_paths(--xsm-max-paths,--xsm-max-depth);--xsm-path-guards true|false|bothalso reaches the configurations that only a False guard or a failing service leads to.- State & transition coverage (#270). Core
xstate_statemachine.coverage.CoverageCollectoris aPluginBasethat records every configuration entered and every chart transition taken. Parallel configurations mark all leaves and their ancestors, history restores mark what was re-entered, and restored interpreters count atstart().report(machine)returnsCoverageReport(states_visited, states_total, unvisited, transitions_hit, transitions_total, unhit), usinggraph.transition_coverage_targetsas the denominator. Machines are keyed byid@structure_hash. The collector is thread-safe. It renders to a stable{"version": 1, ...}JSON document, a single self-contained HTML file, or a terminal summary. In pytest,--xsm-coverageregisters one collector through the existingplugins.register_globalfor the whole session (unregistered at the end), so interpreters a test builds itself are counted as well as the fixtures’ ones. Related options:--xsm-coverage-report=term|json[:PATH]|html[:PATH],--xsm-fail-under-state-coverage=Nand--xsm-fail-under-transition-coverage=N. The newxsm coverage report.json [--fail-under N] [--plain] [--json]renders the report in CI. Hardened in the #270 battle – see “Coverage gates, as battle-tested” under Changed. New benchmark rowcoverage_collector_overhead(unbudgeted). - Hypothesis model-based testing (#271).
xstate_statemachine.contrib.testing.model_test(chart, *, logic=, invariants=, state_assertions=, payloads=, clock=True, max_steps=50, settings=, allow_denied=False, snapshot_roundtrip=True, guard_flip=False)generates a HypothesisRuleBasedStateMachinethat pytest collects (TestX = model_test(...)). There is one rule per declared event. Each rule draws its payload first and sends only whencan(Event(type, payload))is true, so only legal sequences are generated.can()runs real guards when you pass real logic, so keep guards pure. Further rules advance the clock over the declaredafterdelays, round-trip a snapshot mid-sequence, and flip stub guards. Payloads are inferred fromevent_schemas/ pydanticEventModelfields. When an invariant fails, Hypothesis shrinks the run to a minimal sequence andmodel_testwrites it as anxsm simulate --scriptfile (failing.json, or into--xsm-failing-dir).events_strategy(chart, length=)is a plain strategy of legal event sequences.hypothesisis imported lazily: without it,model_testraisesMissingExtraErrornaming[testing], which already pinshypothesis>=6.100. - Entry-point plugin discovery (#296). Third-party packages can ship
plugins, stores and brokers without a core change by declaring entry
points in the
xstate_statemachine.plugins/.stores/.brokersgroups.xstate_statemachine.plugins.discover(group=, allow=, strict=)returnsDiscoveredPlugin(name, distribution, version, obj, hooks, group), andattach_discovered(interp, allow=)constructs each plugin and.use()s it (the hook thatinstrument_all(discovered=True)in[observability]builds on). Discovery is never implicit: nothing loads on import,allow=filters before import,XSM_DISABLE_PLUGIN_DISCOVERY=1turns it off, and a loader that raises is logged and skipped unlessstrict=True. Python 3.9’s dict-shapedentry_points()is shimmed. xsm plugins [--json](#296) lists name, distribution, version, group and thePluginBasehooks each discovered plugin implements.xstate_statemachine.deprecations(#296).deprecated(what, since=, removal=, alternative=)emits aDeprecationWarningonce per call site, anddeprecations()is the registry the policy page renders.ErrorEvent.data,--styleand theevents.engine_*aliases now go through it.- Compatibility matrix (#296).
tests/contrib/compat_matrix.jsonrecords, per shipped extra, the declared floor, the oldest release proven on Python 3.9 and the newest release. A newCompatworkflow runs both cells for every extra (weekly, on demand, and on PRs that touchpyproject.tomlor the matrix), and the docs table is generated from the same file. -
[all]wheel smoke on Linux, macOS and Windows (#296). CI installs the built wheel with[all]into a clean venv, imports everycontribsubpackage, runs each extra’s docs Quick start, and re-checks thatimport xstate_statemachinealone loads no third-party module. - Example app:
examples/integrations/sqlalchemy_orders/(#286). An order lifecycle on SQLAlchemy 2.0 in a sync variant (StatechartMixinwithoptimistic(),send_with_retry,in_statequeries, audit rows) and an async variant (AsyncSQLAlchemyStore+apersisted()on aiosqlite), sharing one database. The 15-minute paymentafteris fired byDueTimerScannerfor orders nobody touches. Ships an Alembic environment and the generated migration for the model and everyxsm_*table; a test runsalembic upgrade headand asserts autogenerate sees no diff. The transactional outbox is not faked: the README points at #293. - Example app:
examples/integrations/flask_wizard/(#286). A 4-step server-rendered onboarding wizard: one machine per browser session (XState+ session-derived key), NEXT / BACK / SUBMIT as events the chart validates,SessionStoreby default with aSQLiteStorevariant (WIZARD_STORE=sqlite), an oversize context refused with 413 and nothing saved, Flask-WTF CSRF when installed, andflask xsm inspect wizard.tests/test_examples_integrations.pynow runs each example’s suite under its own extra, and the[sqlalchemy]and[flask]CI cells run it. -
examples/recipes/(#308). Eight runnable recipe folders (stripe_webhooks,apscheduler_timers,task_queue_workers,form_wizard,slot_filling,feature_flag_rollout,websocket_reconnect,circuit_breaker_retry), each with amachine.jsonthat passesxsm validate. Third-party libraries (FastAPI, Flask, APScheduler, Streamlit, Gradio, Dramatiq, the Stripe SDK) are imported softly, and no dependency is added to the package. - SQLAlchemy & Flask (Phase D) –
[sqlalchemy]extra (#284 parts 1–2). A statechart on your mapped row, or aStateStoreon any RDBMS.StatechartType(JSON;JSONBon Postgres; size-capped both ways) andStatechartMixin: queryablestatechart_state/statechart_state_ids(every parallel leaf),statechart_versionasversion_id_colviaStatechartMixin.optimistic()(a stale write isConflictError;send_with_retryrolls back and retries – 16 threads × 100 sends on one row → exactly 1600),in_state(*ids)matching leaves and ancestors,row.send(event, session=, lock="optimistic"|"pessimistic"). One flush writes the snapshot, the columns, thexsm_deadlinesindex and (with__xsm_audit__) the audit rows – raising insidesend()leaves none of them. A mapper listener keeps the columns true when the snapshot is edited directly.Model.statechart_store()letsDueTimerScannerfire persistedaftertimers.xsm_sqlalchemy_ddl(metadata); Alembic autogenerate proposes no diff (tested).SQLAlchemyStore/AsyncSQLAlchemyStorepass the store contract suite (SQLite, aiosqlite; Postgres opt-in viaDATABASE_URL): conditional-UPDATE optimistic writes, a portable leaselock()that wraps the block in one transaction,due_keys()for the scanner,forget()erasing record + deadlines + lease + log (X0.5), a versioned schema with newer versions refused (X0.10).SQLAlchemyInbox/SQLAlchemyLogshare the store’s transaction so inbox marks and audit rows commit with the save (X0.3).- The transactional outbox (#284 part 3) arrives with the EDA core
(#293), whose
BrokerAdapter/OutboxStoreprotocols it needs. Guide: SQLAlchemy.
- SQLAlchemy & Flask (Phase D) –
[flask]extra (#285). Theinit_appextension Flask never had.XState()/init_app(app, store, lock=, plugins=, inbox=, principal=, log=)keeps per-app state inapp.extensions["xstate"](two apps from one extension share nothing);register(..., authorize=)is required (allow_allwarns once, X0.1);act()ispersisted()with the app’s policies and refuses to run inside a GET;g.xsm;receipt_response()maps status through the corereceiptstable.create_statechart_blueprint(xsm, name, url_prefix, per_event_routes=): state, send, per-event, events, history (log), SSE stream, Mermaid diagram. JSON only (415), capped (413), RFC 9457 problems carrying class names only (X0.7), principal-scopedIdempotency-Keywhose replays are not re-saved (X0.2), GET on a write path → 405 problem. 50 concurrent requests on oneSQLiteStorekey → no lost updates.SessionStore: wizard state in the signed session cookie with a hard 3 KiB cap andSessionStoreTooLargeError.flask xsm inspect|diagram|docs|simulate <name>– output identical toxsm. Flask-WTFCSRFProtectcompatibility documented and tested (exempt blueprint orX-CSRFToken).- Quart shim (
contrib.quart,async with xsm.act()), a soft import – no separate extra; the same route tests run under Quart in CI. Guide: Flask.
- LLM agents (Phase E) –
[agents]extra (#287, #290). The model - LLM agents (Phase E) –
[agents]extra (#287-#291). The model proposes, the machine decides: an agent is theTOOL_LOOPreference chart (contrib/agents/charts/tool_loop.json, Stately-editable, strict andxsm inspectclean) plusagent_logic().- Tool registry –
tool_registry(*fns, timeout_s=, side_effect=)derives JSON-schema tools from signatures via pydantic.run_toolenforces inside itself (X0.13), before executing anything: registration, the active state’smeta.toolsallow-list, strict argument validation, per-call human approval forside_effect=Truetools, a mandatorytimeout_s, and output truncation. A denied call raisesToolDeniedErrorand is never executed – including one an injected tool result talked the model into. - Budgets and timeouts –
budget_guards(max_tokens, max_usd, max_turns)on the single entry to every model turn;aftertimeouts on model and tool calls withRetryPolicybackoff. - Human-in-the-loop as a durable state –
awaiting_humanpersists in any store with its escalation deadline, resumes onHUMAN_APPROVEDafter a restart, escalates viaDueTimerScanner. - Structured output –
output_model=/meta.output_model; invalid replies are re-prompted (RETRY_OUTPUT) and count against the budget. run_agent/run_agent_sync(withapersisted/persisted),FakeModelfor offline tests, boundedmessages(max_messages/summarise), default redaction ofapi_key/authorization/*token*keys,AgentTracePlugin(JSONL,gen_ai.*fields,record_content=Falsedefault,on_spanseam for OTel).- Providers –
providers.openai.openai_modelandproviders.anthropic.anthropic_model; the SDKs are soft imports (MissingExtraErrornamespip install openai/anthropic), contract-tested against recorded fixtures. - Multi-agent recipes – example
supervisor,pipelineanddebatecharts;spawn_agent(child_chart, model, tools, budget=)spawns aTOOL_LOOPactor per sub-task with its own budget and a tool allow-list that must be a subset of the parent’s (refused loudly otherwise);BudgetPluginrolls child usage into the parent and raisesBUDGET_EXCEEDED;handoff_guardmakes an unauthorised handoffReceipt.denied. Guide: LLM agents. - LangGraph interop (#288) –
contrib.agents.langgraph(soft importlanggraph, tested>=0.2,<2.0; outside that range the import raisesImportErrornaming it).statechart_noderuns a statechart as one LangGraph node with its snapshot as plain JSON in the graph state (so any checkpointer persists it);route_by_statechartroutes conditional edges by active state (an unmapped state is loud, never a silentEND);langgraph_serviceruns a compiled graph as aninvokeservice (ainvoke, orastreamchunks asSTREAMevents), with errors toonErrorand cancellation on state exit;LangChainCallbackPluginmirrors transitions into a LangChain callback handler. Tools in aTOOL_LOOPnode still run only throughrun_tool(X0.13). It ships insidecontrib.agents, and a separate distribution is the plan if LangGraph churn bites. - pydantic-ai and structured output (#289) –
contrib.agents.pydantic_ai.pydantic_ai_service(agent, prompt_from=, deps_from=, stream=)runs a pydantic-aiAgentas aninvokeservice. Output and usage go toonDone, andusage_logic()merges usage into the contextbudget_guardsread. Withstream=True, text deltas areSTREAMevents.agent_tool_from_machine(runner)exposes a statechart run as a pydantic-aiTool.structured_output(Model, retries=2)is the public switch for the existingRETRY_OUTPUTmechanism (per-statemeta.output_model), using instructor’s JSON extractor when installed;agent_logicgainsoutput_parser=. - Comparisons, example, launch drafts (#291) – guide pages vs
LangGraph, vs Burr and vs @statelyai/agent, with feature tables
generated from
docs/_data/comparisons.json;examples/integrations/agents_support_bot(order lookup via the FastAPI example, a refund gated byawaiting_human, budgets, a JSONL trace,SQLiteStore;run.py --fakeruns offline); a README “For LLM agents” section; PyPI keywordsllm-agentsandagent-orchestration. Launch-post drafts sit underdocs/research/launch/and are not published.
- Tool registry –
- Adoption kit (#309).
xsm new --template fastapi DIRscaffolds a minimal project from thefastapi_ordersexample –machine.json,models.py,logic.py,app.py,static/,tests/,README.mdand arequirements.txtpinningxstate-statemachine[fastapi]– with the standard library’sstring.Template(no cookiecutter).--name(lower_snake_case, defaultorders) sets the URL and store prefix and the camelCased machine id; a non-empty directory is refused without--force;--listshowsfastapiand the planneddjango(#280) /flask(#285) templates, which are refused with their issue. Exit 2 on any refusal. Also in the interactive launcher.xsm-checkGitHub Action (action.yml, composite): installs the library, runsxsm validate --plainon afilesglob and, whengenerated-diris given,xsm gt --check.setup-pythonis pinned to the same SHA as CI. Self-tested on the example app in.github/workflows/xsm-check-selftest.yml.- pre-commit hooks (
.pre-commit-hooks.yaml):xsm-validate(*machine.json) andxsm-gt-check(pass_filenames: false; pass your generation flags asargs). - Editor JSON Schema
schemas/xstate-machine.schema.json, generated fromcontrib.pydantic.MachineConfigbyscripts/gen_machine_schema.py; a test regenerates it and fails on drift, and every machine in the test corpus validates against it. - PyPI metadata: classifiers
Framework :: FastAPI,Framework :: AsyncIO,Framework :: Pytest; keywordsstatechart,xstate,fsm,state-machine,workflow-engine,saga,event-driven,fastapi,persistence. The sdist now includesschemas/,action.ymland.pre-commit-hooks.yaml.
- Web framework integrations (Phase C).
[starlette]extra (#275).StatechartRegistryruns named machines over anyStateStore/AsyncStateStorewith the honest multi-worker model – create → act → persist → discard:async with registry.act(name, key)builds one asyncInterpreterviaapersisted, saves withexpected_version, and publishes each changed receipt to this process’s SSE/WebSocket subscribers only after the save commits.register(authorize=)is required (closed by default;allow_alllogs a one-time WARNING); responses carry state only unless acontext_serializer=is given.receipt_to_status/ReceiptResponsemapReceiptto HTTP (denied 409, deferred 202, duplicate 200, error 500),problem()emits RFC 9457application/problem+jsonwithout exception text, andstatus_for_exceptionmaps strict-mode 422, drift/version 409 (with amachine_versionhint), conflict/lock 409 and missing key 404.send_event()honoursIdempotency-Keythrough the principal-scopedIdempotencyPlugin(reused body → 422, in flight → 409), accepts only size-cappedapplication/json(415 / 413).transition_stream()(SSE) andwebsocket_endpoint()stream a snapshot on connect and onetransitionper committed change with a monotonically increasing sequence, 15 s heartbeats,Origin/Hostchecks andmax_connections_per_key. Opt-inresident()actors are LRU/TTL bounded;lifespanrunsDueTimerScannerwhenrun_timers=Trueand drains residents withindrain_timeout_s;health_route()/ready_route()probes;mount_inspector()refuses unlessdebug=True(the sink ships with #274). Guide: Integrations → Starlette.[fastapi]extra (#276).StatechartRouter(registry, name)turns a registered chart into anAPIRouter:GET /{id}(state only),POST /{id}/sendwhose body is a discriminated union of the machine’sEventModels ontype(or a deterministic{type: Literal[<declared>], payload}fallback), onePOST /{id}/events/<EVENT>per declared event,GET /{id}/events(whatcan()accepts now + declared schemas),GET /{id}/diagram.mmd, SSE/{id}/streamandWS /{id}/ws. Every handler delegates to the[starlette]registry (send_event,peek,transition_stream,websocket_endpoint), so status mapping,Idempotency-Keyandauthorizeare shared; the principal comes from anactordependency, never the body.app.openapi()validates, documents every 4xx/5xx asapplication/problem+json, and request-validation failures are 422 problems listing only field locations and error types.dependencies=/per_event_dependencies=add FastAPI-native auth (a gated event is refused on/send).get_interpreter()is aDependsyielding insideact()– saved when the handler returns, not when it raises, a conflict → 409.instrument_app()mounts probes, composesregistry.lifespanwith the app’s own (compose_lifespan()) and maps library exceptions to problems. Extra:fastapi>=0.100,pydantic>=2.5,starlette>=0.27. Guide: Integrations → FastAPI.[litestar]extra (#278).create_statechart_controller()generates aControllerwith the same route table;XStatePluginappendsregistry.lifespan, mounts probes, maps library exceptions to problem+json, documents the/sendbody asoneOf+discriminatorin OpenAPI and can register per-machineProvide()dependencies;get_interpreter()is theProvideform. Litestar requests and responses are adapted at the edge – one registry, one set of semantics. Extra:litestar>=2.0,starlette>=0.27. Guide: Integrations → Litestar.EventModelvalidators ignore the transport-levelidempotency_keypayload field, soIdempotency-Keyworks withextra="forbid"models.examples/integrations/fastapi_orders+ multi-worker guide (#277). A runnable order-lifecycle service (cart → paying → paid → shipped,afterexpiry, an invoked gateway retried withRetryPolicy, a parallelfulfilmentregion) onStatechartRegistryoverSQLiteStoreor, withXSM_REDIS_URL,RedisStore; typed events,Idempotency-Key, aBackgroundTasksconfirmation email scheduled only for a committed first-time change, SSE consumed by a static page, and a--role schedulerprocess runningDueTimerScanner(timers in exactly one process).loadtest.pystarts N uvicorn workers (Windows-safe launcher polling/_xsm/health) and fires 200 concurrentPAYs at one order: exactly one changed receipt, with and without a key. Docker Compose runs 4 workers + scheduler + Redis. The FastAPI guide gains Multi-worker deployments, Side effects, Sessions & wizards, Testing and Troubleshooting;tests/test_examples_integrations.pyvalidates every example chart and runs each example’s suite in the[fastapi]CI cell.- Fixed:
send_event()no longer persists a duplicate / in-flight receipt. Saving a replay bumped the version so the ORIGINAL request’s save lost with 409; under a burst of retries with oneIdempotency-Key, nobody won. xsm gt --with-api/--with-models(#279). Two companion templates:fastapi-routerwrites<machine>_api.py, an editableAPIRouter(GET /{id}, one typedPOST /{id}/events/<EVENT>per event viaDepends(get_interpreter),ReceiptResponse, an undeclared event → 404 problem) whoseauthorizestub raisesNotImplementedErroruntil implemented (X0.1, closed by default);pydantic-modelswrites<machine>_models.py, a contextBaseModelplus oneEventModelper event (declared payloads typed, elseextra="allow"). With the extra installed the generator mounts the router on a throwawayFastAPI()and checksopenapi()lists one route per event;--check/--diffreport drift. Listed byxsm list-templatesand the launcher. Guide: CLI templates → Companion templates.
- Graph algorithms
shortest_paths,simple_paths,reachable_states,transition_coverage_targets,Path,Step(#269; core, zero-dep; the Python counterpart of@xstate/graph). Every candidate step is EXECUTED by the real engine on aSimulatedClockwith stub logic, so parallel regions, history,aftertimers andonDone/onErrorbehave exactly as at runtime – no hand-written semantics.guards="both"explores each guarded step both ways and records the assumption a path relies on (guard:x=False,service:s=error,delay:d=unknown);weight="time"is Dijkstra overafterdelays.Path.replay(interp, clock)lands exactly onfinal_states;Path.event_string()is thexsm simulate --eventsgrammar. New CLIxsm paths machine.json [--simple] [--guards both] [--json];xsm inspectgains one finding the static reachability pass cannot produce – “state is never entered by the engine (reachable statically only)” – and never removes a static one (corpus output unchanged). Guide: Testing § “Path generation”; CLI § “Paths”. The traversal never modifies the caller’s machine (it explores a private copy, so concurrent calls and live interpreters are unaffected);max_configs=100_000bounds a combinatorial chart withExplorationLimitError(partial result on.found); wildcard ("*","mouse.*") handlers are explored. [testing]extra: a pytest plugin (#268; both engines). Registered through apytest11entry point, sopip install "xstate-statemachine[testing]"is the whole setup; the plugin imports only pytest + core, is a no-op without the marker, and-p no:xstate_statemachinedisables it.@pytest.mark.xstate_machine( source, logic=, strict_config=, strict=)builds the machine from a JSON path (relative to the test file, then rootdir), a dict or aMachineNode– onstub_logicunlesslogic="pkg.module:callable"– and serves thexsm_-prefixed fixtures:xsm_machine,xsm_clock(SimulatedClock),xsm_interp(startedSyncInterpreter, stopped at teardown),xsm_ainterp(async twin; skips namingpytest-asynciowhen it is absent),xsm_ran(stub actions that ran),xsm_guards(the live stub-guard table),xsm_store(MemoryStore),xsm_send_all/xsm_asend_all(thexsm simulate --eventsgrammar,+Nadvances the clock) andxsm_snapshot(file-backed assertion onstate_ids/value/context/status;--xsm-update-snapshotsrecords, a mismatch is a unified diff, files are byte-identical across runs).@pytest.mark.xstate_guards_false("g")forces stub guards; with real logic it is apytest.UsageError, as is every malformed marker.pytest --xsm-versionprints the library version.xsm gt -t pytest --fixturesemits the recorded-trajectory scaffold on the plugin’s marker and fixtures (default output unchanged). Guide page pytest.- Security and operability baseline X0 (#303) closed out for Phase A.
SECURITY.mdat the repository root states the trust model (installed packages and the machine definition are trusted; events and snapshots are not), the reporting process and the supported versions. Two new guide pages: Guarantees – the crash-consistency specification (order of actions / save / inbox mark / timer fire, what is exactly-once and what is at-least-once, every crash window with the test that proves it) – and Security – every X0 item mapped to the test or CI job that enforces it. New in code:FileStorerecords carry aformatversion (X0.10; a newer format is refused with an upgrade message, never guessed at); apip-audit --strictCI job over the[all]extra, which now lists every shipped extra (X0.14);tests/test_security_baseline.pygrepssrc/forpickle/yaml.load/eval/exec(one justified, marked exemption in the CLI verifier) and refuses any GitHub Action that is not pinned to a commit SHA. - Integration performance budgets (#307): a reproducible seven-run
import, persistence, plugin, validator and snapshot benchmark for both
engines, with reviewed p50 baselines and a 25% regression margin. A
deterministic default-job guard detects eager contrib/third-party
imports; timing gates run only on the nightly reference runner
(
XSM_PERF=1). Unshipped integrations remain explicitly unmeasured. - Actor logic helpers
from_callback,from_async_iterator,from_iterator,from_coroutine,from_callable,from_interpreter(#267; both engines) – XState v5fromPromise/fromCallback/fromObservable/fromActorparity as ordinary services. A callback service’ssend_backis thread-safe from any thread or loop (async:call_soon_threadsafe; sync: the #305 mailbox drained on the owner’s nextsend()/tick()– never the plain queue);receive(handler)gets events the parentsendTos the invocation id (sendTonow resolves running actor logic by invocation id); cleanup runs exactly once on state exit,stop()or setup error,async defcleanups are awaited by the asyncstop(). Streams deliver each item asEvent("STREAM", {"data": item})in order,onDonewith the last item,onErroron exception,aclose()/close()on state exit. Docs: services guide “Actor logic helpers” (parity table, paho-mqtt-shaped callback and LLM-stream examples, executable). [pydantic]extra: typed context, typed events, static config validation, JSON Schema (#266). Built on core seams only – core never imports pydantic.context_model(Model)builds thecreate_machine(context_validator=)callable (a failure isContextValidationError, an action error, sorollbackkeeps the machine valid; defaults and coerced values are written back into the dict);typed_context(Model, cfg)validates the initial context;TypedContextPlugin(Model)re-coerces on start, fresh or restored (Decimalcomes back aDecimal);PydanticCodec(Model)is a store codec that keepsDecimal/datetimeexact at rest;context_of()for a typed view.EventModelsubclasses ARE events (__xstate_event__) andevents_union(*models)is theevent_schemas=mapping (bad payload → the existingInvalidEventPayloadErrorwith the pydantic error ascause).validate_machine_json(raw, strict=)is a Pydantic model of the XState subset the parser implements, erroring with JSON paths beforecreate_machine; a lock-step test pins its fields tovalidation.KNOWN_*_KEYSand it passes the whole Stately corpus.machine_json_schema(machine, events=, context_model=)emits a JSON Schema (discriminated event union, context, state-id enum, machine id/version/hash). Guide page Pydantic.[redis]extra:RedisStore/AsyncRedisStore,RedisInbox,RedisLog(#306) – the first shipped integration underxstate_statemachine.contrib. Shared state for multi-worker / multi-host deployments implementing the sameStateStore/AsyncStateStore,InboxStoreandTransitionLogStoreprotocols, sopersisted(),apersisted(),IdempotencyPlugin,AuditPluginandDueTimerScannerwork unchanged – and pass the SAME contract suites as the stdlib backends (run onfakeredisin CI, live server viaXSM_REDIS_URL). Optimistic save is one atomic Lua script; the pessimistic lock is a token-ownedSET NX PXand fenced (an expired lock yieldsConflictError, never a lost update);forget()is atomic;list_keys()escapes glob metacharacters;prefixis mandatory (X0.15) with a{prefix}:schemakey; deadlines are indexed in a sorted set the scanner reads directly.RedisInboxcomputes and compares every expiry on the Redis server clock (hosts with skewed clocks agree on whether a key is live).RedisLoguses the record’sseqas the stream id with a compare-and-append script, so concurrent writers never mint the sameseq,append_nextis atomic across hosts andread(after_seq=)is a rangedXRANGE; itsmaxlendefaults to unbounded (it was 10 000 – a silently trimmed audit head). Guide page Redis with guarantees and threat-model boxes; the boxes state that snapshot, inbox mark and log record are three atomic writes, not one transaction.require_extra()now turns every failure mode – not installed, import refused by a finder, installed-but-broken – intoMissingExtraError(it used to let a rawImportErrorescape from the subpackage’s own import). Docs:<!-- doc-requires: mod, ... -->marks an example that runs only when the named modules import (an extra’s CI cell runs it).- Durable
aftertimers: persisted deadlines,restart_timers="resume" | "fire_due",pending_deadlines(),DueTimerScanner(#264; both engines). Every armedaftertimer is now recorded in the snapshot’sdeadlines(layout v4) as a wall-clockDeadline(state_id,entry_seq,due_at_wall, resolveddelay_ms,event_type) – reversing the 0.8.0 “timers are not persisted” decision, because under create → act → persist → discard an in-memory deadline never fired.from_snapshot(restart_timers=)widens from a bool toFalse | True/"restart" | "resume" | "fire_due": resume re-arms the REMAINING wall time, fire_due also fires matured deadlines duringstart()in deadline order;persisted()/load_interpreter()default to"resume". A parked deadline whose state vanished after a migration fails loudly (StateNotFoundError).DueTimerScanner(store, machine_for_key)–run_once(now)/scan()/run_forever()– is the zero-dependency driver that wakes due machines under a lock strategy, re-reading under the lock so a machine another worker advanced is skipped;ScanResultcarries the lag metric. Docs: “Durable timers” with the guarantees box (no earlier than the deadline, no later than the next tick; at-least-once on crash – pair with the inbox). - Chart versioning on restore:
MachineVersionMismatchError,SnapshotMigrator,from_snapshot(on_version_mismatch=, migrator=),xsm snapshots --stale(#263; both engines). A snapshot’smachine_versionlabel (written since #305) is now compared withmachine.versionon restore: a mismatch raisesMachineVersionMismatchError(aSnapshotDriftError, distinct from the layout-levelSnapshotVersionError) unlesson_version_mismatch="warn"or aSnapshotMigratorwith a registeredfrom → tostep (chained along the shortest path;NoMigrationPathErrorotherwise) is given. A migrated blob has its hash dropped and is validated against the new machine like any other (every state id must exist, X0.4;strictstill applies); child actors follow the same policy with steps scoped bymachine_id. Unlabelled (0.10.x) blobs restore with a warning.persisted()/apersisted()/lock.run()passmigrator/on_version_mismatchthrough. Newxsm snapshots --store sqlite:///… [machine.json] [--stale] [--json]lists a store’s keys with record version, machine version and age, or only the stale ones. Docs: “Versioning in-flight instances” with the rollout recipes and an explicit “we do not automatically migrate”. persistence.TransitionLogPlugin/AuditPlugin, log stores (MemoryLog,JSONLinesLog,SQLiteLog) andreplay()(#262; both engines). One append-onlyTransitionRecordper processed event – transitions AND denied / unhandled / deferred / errored attempts, each with adisposition– written from the hook that observed the outcome, with a gap-free per-keyseq, redacted payload, the actions that ran, andactor/reason/correlation_id(payload keys or thecorrelation_id_varcontextvar).SQLiteLog(store)shares the store’s connection so the row joins the snapshot’s transaction underPessimisticLock;append(..., connection=None)is the seam for Django / SQLAlchemy.replay()rebuilds a machine from the log on aSimulatedClock: user events re-sent,aftersteps by advancing the clock, service completions by stub services replaying the recordeddone/error– never re-sending engine-minted events – with stub logic unlesslogic=is given, andReplayDivergenceError(seq)on the first mismatch. Docs: “Audit log & replay” with an approval-workflow example and the “event sourcing lite” scope note.persistence.IdempotencyPlugin+InboxStore(MemoryInbox,SQLiteInbox) (#261; both engines). At-least-once deduplication as a plugin: an unseen idempotency key is claimed atomically, a redelivery is answered from the inbox with the original receipt (duplicate=True) before the machine sees it, a reused key with a different payload is refused (IdempotencyMismatchError→ 422) and an in-flight key answers 409 – refusals are receipts, not exceptions. Scope isprincipal / machine / instance(X0.2;principalis required); fingerprint = sha256 of type + canonical payload minus the key; TTL 7 days; keys ≤ 255 printable ASCII. Crash-consistent withpersisted()(X0.3): marks are buffered and written after the snapshot save (inside the same SQLite transaction whenSQLiteInbox(store)shares the store), and a 64-keyprocessed_idsring inside the snapshot covers the save→mark window – three fault-injection tests.interpreter.store_key(new attribute) records the keypersisted()/load_interpreter()loaded a machine under.receipt_to_statusgains 422 andSTATUS_UNPROCESSABLE. Docs: “Idempotency: the inbox” with a Stripe-shaped example and the at-least-once + inbox guarantees box.persistence.persisted()/apersisted()/persisted_retry()and lock strategiesOptimisticLock/PessimisticLock/NoLock(#260).with persisted(store, key, machine) as order: order.send("PAY")is create → act → persist → discard with a started interpreter, persisting on clean exit and writing nothing if the block raises. A block cannot be re-run, so under the defaultOptimisticLocka concurrent write raisesConflictErrorat exit and the caller retries;persisted_retry(fn)/lock.run(fn)is the retrying form (guarantee: fn and its actions may run up toretries + 1times per logical send – keep side effects in services or an outbox).PessimisticLockholdsstore.lock()for the block and still saves withexpected_versionas a fence.SQLiteStore.lock()is now the thread’s ownBEGIN IMMEDIATEtransaction (saves inside the block join it and commit together);FileStore.save()inside its ownlock()no longer self-deadlocks;as_async()funnels every call through one worker thread soasync with adapter.lock(): await adapter.save()is correct by construction. Docs: “persisted()” and “Concurrency: choosing a lock” sections with the decision table.persistencestores:StateStoreprotocol +MemoryStore/FileStore/SQLiteStore,load_interpreter/aload_interpreter/save_interpreter,as_async()(#259; zero-dependency). The create → act → persist → discard model with optimistic locking (save(expected_version=)→ConflictError) and pessimisticlock(key)(LockTimeoutError). Every backend passes one contract test suite. Safety rails on all of them:max_snapshot_bytes(1 MiB) on save AND load (SnapshotTooLargeError), key validation (InvalidKeyError),forget(key), acodec=seam,health().FileStore: atomic writes, percent-encoded keys (never the raw key in a path; case-collision-free), advisory locks with stale reclaim, 0700/0600 modes, documented as unsafe on network shares.SQLiteStore: WAL, connection per thread,xsm_schemaversioning,database is locked→LockTimeoutError, 0600 files, UNC warning. Guide page Persistence Stores; newStoreErrorfamily inexceptions.xstate_statemachine.patterns:RetryPolicy,DeadLetterPlugin,CircuitBreaker(#265; zero-dependency; both engines). Guide page Resilience Patterns.RetryPolicy(max_attempts, base_ms, factor, max_ms, jitter, rng): AWS-stylenone/full/equal/decorrelatedjitter, capped, deterministic with an injectedrng.policy.logic()yieldsretryDelay(named delay),retryCanRetry,retryBump,retryResetso the documented four-state retry chart works verbatim.DeadLetterPlugin(sink): on entry to a state taggeddead-letter(or instate_ids) emits oneDeadLetter– event, attempts, the error chain fromon_service_error/on_action_error, and a snapshot – redacted before it reaches the sink (#303).DeadLetterStorein-memory sink withpurge_older_than().CircuitBreaker/@circuit_breaker: Nygard’s breaker as a statechart (CIRCUIT_BREAKER_CONFIG, renderable byxsm inspect) on a lock-guardedSyncInterpreter;call/acall; fastCircuitOpenErrorwhen open; half-open admits exactlyhalf_open_max_callsprobes under a 32-thread hammer; cooldown on the injected clock.MachineLogic.merge(*others)returns a new combined logic (later wins; nothing mutated).- Both engines now fire
on_event_processedwith the step settled, so a plugin may callget_persisted_snapshot()from it (the sync engine previously still reported mid-step there).on_transitionremains mid-step and still refuses a snapshot.
- Global plugin registry,
context_validatorseam,__xstate_event__adapter,SyncInterpreter.send_threadsafe()(#305 part 2; both engines).register_global(plugin)/unregister_global(plugin)/global_plugins()(top-level exports;plugins.clear_global_plugins()for teardown). A registered plugin is attached – with the same containment as.use()– to every interpreter constructed after registration: both engines,from_snapshot, and engine-spawned children. Opt-in only; the library never registers anything itself. Thread-safe (100-thread registration test).create_machine(..., context_validator=fn):fn(context)raises when the context is invalid. Both engines call it after any action that changedcontext(never when unchanged) and treat a raise as that action’s failure, soactionErrorPolicyapplies ("rollback"restores the pre-transition context) andon_action_errorfires. Stored onMachineNode.context_validator. The seam for the pydantic extra (#266).- Any object implementing
__xstate_event__() -> str | dict | Eventis accepted wherever an event is:send,send_events,send_threadsafe,can,sendTospecs – one normaliser, one level, same rules as a direct argument. Keyword payload merges over an adapter’s dict. SyncInterpreter.send_threadsafe(event, **payload): the only legal cross-thread entry to a sync machine. A locked mailbox the owning thread drains at the top ofsend()/tick(), ahead of its own event, each as its own macrostep with the normal admission checks. FIFO per producer thread; nothing lost (8 threads x 1,000 events test); an admission refusal at drain time surfaces on the owner viaon_event_dropped(..., "invalid")+last_error.
- Snapshot layout v4,
MachineNode.version, wall clock,Deadline, receipt codec (#305 part 1 — core prerequisites for the persistence and web integrations; both engines).SNAPSHOT_VERSIONis 4. Every snapshot now carriesmachine_version(the chart’s root"version"label, ornull) anddeadlines(durable wall-clockaftertimers, #264 — always[]until that lands, so the layout is settled once). v0–v3 blobs upcast withmachine_version=None, deadlines=[];check_shapevalidates both keys (SnapshotCorruptError);check_versionrefuses v5+. Rolling deploys: a v4 blob does not load on 0.10.x (SnapshotVersionError) — deploy readers before writers; see the snapshots guide.MachineNode.version: Optional[str]reads the root"version"key thatvalidation.pyaccepted and silently ignored. Not part ofstructure_hash, so re-labelling a chart keeps old snapshots loadable.xsm inspectshows it (--jsonadds"version").interpreter.wall_now()on both engines — epoch seconds viaclock.wall_now()when the clock has one (RealClock→time.time();SimulatedClock(wall_start=…)→wall_start+ virtual elapsed, so a test can say “restarted an hour later”), elsetime.time(). Anything persisted anchors to this, never toclock.now().persistence.Deadlinefrozen dataclass (state_id,entry_seq,due_at_wall,delay_ms,event_type;to_dict/from_dict/remaining_ms) andcheck_deadline_record().entry_seqis the state-entry generation, so a deadline armed by an earlier visit is dropped on fire rather than honoured.xstate_statemachine.receipts:receipt_to_status()(error → 500, deferred → 202, denied → 409, else 200;duplicateis neutral),receipt_to_json()/receipt_from_json()witherroras{"type", "message"}strings only (never pickled, #303) and aReceiptErroron the way back. Also exported from the top level.
- Plugin hooks
on_before_sendandon_event_processed(#304; both engines, parity-tested).on_before_send(interpreter, event)fires fromsend(),send_events()andsend_threadsafe()after thestrict/event_schemasadmission checks and before the event is queued; returning aReceiptshort-circuits the send — the event is never queued,on_event_received/on_event_processeddo not fire for it, and the caller receives that receipt (asend(wait=True)resolves to it immediately). First plugin to return wins. It is fail-open: a raising interceptor is reported viaon_plugin_errorand the event is admitted, so a blocker must return a receipt, not raise. This is the seam the idempotency inbox (#261), rate limiting and maintenance-mode plugins use.on_event_processed(interpreter, event, receipt)fires exactly once per event that entered the machine — user and engine-minted alike — after it settled or was denied / unhandled / deferred / dropped, with the sameReceiptawait=Truecaller gets; the outcome hook that audit, coverage, tracing and the inbox’s “mark” attach to. Both engines build the receipt from a per-event before-image, so a syncsend()that also drains a due timer reports each event separately; the bookkeeping runs only when an attached plugin overrides the hook.LoggingInspectorimplements it at DEBUG. Receipt.duplicate(#304) — sixth field, defaultFalse; set by anon_before_sendinterceptor answering a redelivered event with the original outcome. Appended last so positional unpacking of the five older fields still works; prefer attribute access.stub_logic()/logic_names()(#304) —xstate_statemachine. testing_utils, exported from the package root: aMachineLogicthat satisfies every name a chart declares (actions record intoran, guards answer a fixed value or a live mapping, services complete synchronously withservice_results), so tools and tests can drive any chart without its business logic. Promoted from the CLI’s internal trace recorder; thepytestcodegen template andxsm simulatenow share it. Builds every machine in the example corpus and the Stately fixtures.- Integration programme scaffolding (#258; epic #257). The library
is growing optional framework integrations while the core stays
zero-dependency — a promise now enforced rather than asserted:
xstate_statemachine.contrib— the home for optional integrations (Django, FastAPI, SQLAlchemy, Celery, brokers, observability, testing, LLM agents), one pip extra each. The package imports nothing; each subpackage begins withrequire_extra(...).MissingExtraError(also anImportError) — raised when an integration is imported without its extra; the message is the exactpip install "xstate-statemachine[<extra>]"command. Exported from the top-level package.xstate_statemachine.persistenceis now a package. The snapshot envelope module moved topersistence/snapshot.py; every public name (SNAPSHOT_VERSION,structure_hash,check_version,upcast, …) is re-exported, so existing imports are unaffected. Stores, locking, idempotency and durable timers land here in the next issues.- 22 integration extras declared in
pyproject.tomlas empty placeholders (pydantic,fastapi,django,sqlalchemy,celery,redis,testing,observability,agents,web,eda,all, …) so they already resolve; each fills when its issue ships.contrib/_registry.pyis the single table tests and CI check against. tests/test_zero_dependency.py— a subprocess guard that blocks every non-stdlib import, imports the package the way a user would, walks every core module, and assertscontribwas never touched. Proven to fail on a planted third-party import.- CI:
core-zero-depjob (bare install + the guard), acontribmatrix with one cell per extra that installs only that extra,pytest-socketon the default suite (no accidental network), a grep banningpickle/yaml.load/eval/execundersrc/, and all actions pinned to commit SHAs. - Docs: an Integrations guide page and sidebar section; a page template every integration copies (Install / Quick start / Reference / Guarantees / Threat model / Compatibility / Troubleshooting).
- PR template gains an integrations checklist, including “this PR does not tag or publish a release”.
Documentation
- Deprecation Policy page (#296). SemVer covers the core and the
persistence layer;
contribAPIs are provisional. A deprecated API warns for at least one minor release and is removed no earlier than the next major. The page includes a current-deprecations table that a test keeps in sync with the registry. It is linked from this changelog’s header and from the README’s new “Versioning & support” section, which also states the supported Python range. - Compatibility page (#296), generated from the matrix. Five declared
floors do not pass on Python 3.9, and the page shows declared vs oldest
tested with the reason for each:
[fastapi]0.100 -> 0.106.0,[litestar]2.0 -> 2.14.0,[sqlalchemy]2.0.0 -> 2.0.2, and[starlette]0.27 -> 0.45.3. For[flask], 2.3.0 passes, but its Quart shim needs Werkzeug 3. -
Plugins guide: “Third-party plugins: discovery” and “Writing a third-party plugin” (#296). SECURITY.md gains the plugin trust model (discovered plugins run in-process with full privileges), and the security page’s X0.14 row covers discovery.
-
Comparison pages vs django-fsm, transitions and python-statemachine (#286). Feature tables (hierarchy, parallel, timers, invoke, actors, async, locking, versioning, admin, REST/DRF, audit, visual editor, typed context, persistence, XState JSON) render from three new entries in
docs/_data/comparisons.json; every row carries a source note naming the competitor version checked, and a test enforces it. Each page has a side-by-side order lifecycle (ours executed by the docs test, theirs fenced astext) and an honest “When to choose them instead” section. The django-fsm page has a migration recipe pointing at the plannedxsm_migrate_fsm(#310). Linked from the README, the landing page and the Integrations journey. - Recipes pack (#308). A new Recipes section (index page, linked
from the README Cookbook and the Integrations journey) with eight worked
recipes. Each has a Stately-importable chart, 30–60 lines of Python, an
xsm simulate --events …line that a test replays, and a test undertests/recipes/:- Stripe webhooks: subscription lifecycle (
incomplete → active ↔ past_due → canceled), with FastAPI and Flask endpoints. They verifyStripe-Signaturewith a stdlib constant-time HMAC and a 300 s timestamp window (stripe.Webhook.construct_eventwhen the SDK is importable), mapevent.typeto machine events, deduplicate onevent.idviaIdempotencyPlugin, and persist viapersisted(). Tested with recorded fixtures: a forged signature, a tampered body and a stale timestamp are rejected, and a replayedevent.idis applied once. The Guarantees box cites X0.2 and X0.7. - APScheduler durable timers:
DueTimerScanner.run_onceas a cron or interval job (max_instances=1,coalesce=True), with 7-day and 14-dayafterfollow-ups and--role schedulerparity with the FastAPI example. - RQ / arq / Dramatiq workers: load → send → persist with an
optimistic retry on
ConflictError, written by hand for each queue and tested through aFakeQueue(8 threads on one key lose no update). - Streamlit / Gradio wizard: back/forward as events and guarded
validation, with only the JSON snapshot kept in
st.session_state/gr.Stateand the diagram embedded. Smoke-tested against stubst/grmodules. - Chatbot slot filling, feature-flag rollout and WebSocket
reconnect (
RetryPolicyjitter +from_callback) are pure engine, tested on both interpreters with aSimulatedClock. - Circuit breaker & retry: an HTTP client over stdlib
urllibthat retries 5xx/429/timeouts but never other 4xx, behind a sharedCircuitBreakerthat fails fast. Tested with a fake transport.
- Stripe webhooks: subscription lifecycle (
- vs AWS Step Functions comparison page: an ASL workflow and the same
workflow in XState JSON side by side, the local-testing story (pytest +
SimulatedClock, no emulator), and when Step Functions is still the right call. Its table is generated from a newworkflow_rowsset indocs/_data/comparisons.json, covered bytests/test_comparisons.py. - Integrations journey (#309). Integrations is now the entry point:
a Mermaid “pick your path” tree (framework × store × worker model ×
events-in) with every leaf linked to an existing page, planned leaves
naming their issue; a 15-minute tutorial whose steps run in CI (
xsm validate/inspect/gt --with-api --with-models --with-tests→ FastAPI onSQLiteStore→ inbox deduplication →RedisStoreviafakeredis), with the coverage gate (#270) and live inspector (#274) steps labelled as not yet shipped; “what you get / what you don’t”; where next. The extras table moved, unchanged, to the new Integration extras page. - Stately editor → Python page: export steps, the
versionkey, whatmeta/description/tags/x-keys do today (meta.publishandmeta.toolsmarked planned),xsm validateafter export, the VS Codejson.schemassnippet for*.machine.json, and the round trip. - CLI: “In CI and pre-commit” and “New project” sections; README badge line under Install.
Changed
- Actor logic helpers, as battle-tested (#267) – a market-data feed
that flaps for an hour (20 000 ticks pushed from the socket’s own
thread across 50 drop/reconnect cycles, both engines) and two
adversary suites:
- A
send_backfrom an exited invocation is dropped. A producer thread does not know the machine left the state; its late event used to land in whatever state came next – and on the next socket’s counter. It is now dropped with a debug log once the invocation’s cleanup ran (SCXML’s rule for a cancelled invocation). send_backrefusesinternal/wait/priorityas payload keys (TypeError). They reached the async engine’ssend_threadsafe(internal=...)as a control and the sync engine’s as payload – the same call meant two things. Send a dict event to carry such a key.- A
send_backthat racesstop()on the async engine (status stillrunning, loop already closing) is dropped with a warning instead of raisingRuntimeErroron the producer’s thread. drain_pending_cleanupsdrains only the current loop’s cleanups and forgets tasks whose loop is closed; a cleanup left behind by a finishedasyncio.run()no longer breaks the next one withValueError("different loop"), and the module-global registry cannot grow with abandoned loops.- A delayed
sendTowhose target invocation exited before the delay elapsed is dropped and reported viaon_event_dropped(reason="unresolved_target"), not delivered to torn-down logic (both engines). If the state was re-entered and a new invocation runs under the same id, that live one receives it. The drop happens between steps, so it is reported but never attached as the next unrelated event’s receipt error. from_iteratoris honest about a blockednext(). A sync iterator stuck in a blocking read cannot be interrupted; it stops at its next item, and the cleanup logs a warning naming the invocation when its thread is still alive (the 100 ms grace wait is skipped on an asyncio loop thread, so N blocked iterators cannot stall the loop). Give blocking iterators a timeout, or usefrom_callbackand let the client’s own thread push.tests/recipesDriver.close()is idempotent (a scenario may stop the machine itself before the fixture does).
- A
- Patterns, as battle-tested (#265) – behaviour changes you can hit:
- The dead-letter error chain now lives in the snapshot under
context["_xsm_errors"]. In production (create → act → persist → discard: a request, thenDueTimerScannerwakes, each in a fresh interpreter) the plugin’s in-memory chain died with every block, and the record arrived withattempts=3, errors=[]. It is cleared on a cleanon_service_done, byretryReset, and after capture;on_interpreter_stopno longer clears it. The chain is per instance (so machines sharing an id no longer mix) and per machine, not per parallel region. A start-time dead-letter state writes no record; the sink runs inside the step; children do not inherit the plugin. DeadLetterPlugin(sink)raisesTypeErrorat construction for a sink that is neither callable nor hasput()(it used to fail at the first dead letter, losing it). A raising sink is logged at ERROR with the record id, then re-raised soon_plugin_errorfires.DeadLetterStore.purge_older_than(NaN)andlist(limit<0)raiseValueError(NaN erased the whole store; a negative limit silently dropped the newest record).RetryPolicy: an rng draw outside[0, 1]or NaN raisesValueError(it produced NaN / negative / over-cap delays silently); NaN parameters and non-finitebase_ms/factorare refused;max_ms=infremains “no cap”.retryResetalso clears the error chain.CircuitBreaker:call()/acall()afterclose()raiseInterpreterStoppedError(the target ran unprotected);cooldown_msmust be finite and ≥ 0 (NaN / inf kept the circuit open for ever, negative half-opened instantly);reset()keepsopened_countas a lifetime counter; thecircuit_breakerdecorator refuses generator / async-generator functions (TypeError) and no longer leaks the first function’s name onto later breakers.
- The dead-letter error chain now lives in the snapshot under
- Durable timers, as battle-tested (#264) – behaviour changes a
0.11.0-RC user can hit:
DueTimerScanner(limit=)caps machines woken per tick, earliest deadline first. It capped the keys scanned in name order, so a scheduler that came back to a backlog larger than the limit woke the same first N keys every tick and the rest never fired.ScanResult.scannednow means keys inspected (all of them on an indexed store).FileStorehas no index: a tick reads every record (~2.4 ms each) – use SQLite / SQLAlchemy / Redis beyond a few thousand records.- A lost optimistic race (
ConflictErroron the fire-save) and aLockTimeoutError(another scanner or request holds the key) count asskipped_stale, noterrors. An operator alerting onerrorswas paged every time two schedulers overlapped. NewScanResult.lockedcounts the lock-timeout subset separately, so a permanently stuck holder is visible (lockedclimbing whileduedoes not fall). skew_tolerance_s < 0orlimit < 1raiseValueError(both were silently accepted).- On
"resume"/"fire_due"the remaining time is clamped to[0, the delay the deadline was armed with](the persisteddelay_ms): a wall clock stepped back between arm and restore made a 5 s timer wait 2 h 5 s; adue_at_wallof 1e308 re-persisted asinfand the next load refused the blob. The bound is the ARMED delay, not today’s declared one, so a chart redeployed with a shorteraftercannot fire an old deadline early (review H2). - Duplicate deadline records for one
(state, event)resolve to the newestentry_seq(earliest due breaks ties). The last record in the list used to win, and a stale record from an earlier visit could fire the current visit early. - A delay resolver returning NaN / ±inf is “unresolvable” (logged, that
timer skipped) instead of a bare
ValueErroron state entry; a negative delay arms at 0 instead of persistingdelay_ms < 0that the next load refused. entry_seqordelay_msof 2**63 or more in a blob isSnapshotCorruptError(a 200-digit seq used to be adopted as the machine’s counter).- Both engines fire matured deadlines inside
start()under"fire_due"; a restored inbox drains before them. A delayedraise(delay=)self-send keeps relative remaining time (#213) and does not shift with the outage the way anafterdoes. - Child actors:
restart_timers,clockandrestart_servicesare forwarded to child restores, and the sync engine’sstart()starts restored children as the async one did – so withrestart_services=Truea restored child’s dormant invoke re-runs too. A child restored as done / error / stopped is left alone on both engines; a resumed child’ssendParentis processed by the samestart(). The root’s deadlines are the only ones the store indexes: a child-only timer cannot wake its parent.
- Versioning, as battle-tested (#263) – behaviour changes a 0.10.x
user can hit on upgrade:
save(..., machine_version=)longer than 255 characters or containing NUL raisesValueErroron every store. Memory / File / SQLite accepted both; the SQLAlchemy and Django columns areVARCHAR(255)and Postgres rejects NUL, so the rule is now the same everywhere and fails at the call site, not in a driver."machine_version": nullin a blob is treated as unlabelled (like an absent key): restores with the once-per-process warning. It was a mismatch ("None" != "1.0") even though the library itself writesnullfor a chart with no"version", so adding a label to a chart refused every blob it had already written.xsm snapshots --staleandmanage.py xsm_snapshots --staleagree with restore: an unlabelled record is not stale (restore warns, it does not refuse) and a chart with no"version"has nothing stale. Both counted them before, so the drain list over-reported.sqlite:///relative.dbinxsm snapshotsandxsm dlqis now relative to the working directory (SQLAlchemy’s rule); four slashes is absolute,sqlite:///C:/...still works./x.dbat the filesystem root was never what the guide’s own example meant. A missing SQLite file orFileStoredirectory is refused (exit 2) instead of being silently created empty and reported as “store is empty”;memory://is refused as meaningless.FileStorewritesmachine_versionbeforesnapshotin the record solist_versionsreads 8 KB per file instead of the blob; older records are read in full and still load. A write failure (ENOSPC, EACCES) raises aStoreErrorthat is also anOSErrorwitherrno, soexcept OSErrorkeeps working; the old record stays and no temp file is left.- The “carries no machine_version” warning is logged once per (machine id, expected label) per process, not once per restore (10 000 stale orders used to log 10 000 lines per deploy).
- A migration step that rewrites
state_idsonly now restores: theconfiguration(leaves + ancestors) is re-derived from the new machine when the step leaves it untouched orNone. The issue’s own recipe was refused (“the two fields contradict each other”). A step that sets the two to disagree isSnapshotCorruptErrornaming the hop. - A step that raises surfaces as
SnapshotCorruptError(the original chained as__cause__) naming the hop; one that returns a non-dict is aSnapshotCorruptErrorthat is also aTypeError. Library errors raised inside a step pass through unchanged. - Requests on charts with chained
definvokes take as long as the chain (they used to fail): seeapersisted(settle_timeout=)above.
-
JSONLinesLog.next_seqis O(1) in steady state (battle-test #262). It scanned the whole file on EVERY send (79 ms at 10 000 records – a run with a JSONL log was quadratic). The last seq per machine is cached while the file size is unchanged; a foreign append, a purge or an external edit forces a rescan (0.012 ms at 10 000).read()still scans the file; useSQLiteLogbeyond a few thousand records. -
SQLiteStoreschema v2: index ondeadlines(key)(battle-test #259, found on CI by the #262 integration). Schema v1 had no index ondeadlines(key), so everyload,saveanddelete(the FK cascade) did a full scan ofdeadlines– O(n) in the store’s deadline count: save+delete read 114 µs → 308 µs → 1.1 ms at 100 / 2 000 / 10 000 keys with one deadline each. A database written by 0.11.0 is upgraded in place on open (oneCREATE INDEX IF NOT EXISTS); the scaling test now saves snapshots WITH deadlines so it exercises that table. -
Performance budgets now gate on every nightly, whatever the CPU (battle-test #307). Three consecutive nightlies landed on three CPU models (AMD EPYC 9V74 / 7763 / 9V45, up to 2.4x apart on identical code) and the absolute-microsecond gate skipped every row on two of them – it was enforced one night in three. Each row is now compared with a cross-CPU reference scaled by the run’s own speed factor (the median of measured/reference over the core rows) and fails above x1.25; cold
import_*rows scale as the square root of that factor. The absolute table stays as a same-CPU report (and as the only catch for a regression that slows every row equally – stated on the Production Characteristics page).tests/test_perf_gate.pypins, untimed, that every recorded CPU passes, a uniform 0.5-2.4x shift passes and a 1.5x single-row regression fails. New report-onlybenchmarks/scaling.pybig-O sweeps run nightly and uploadscaling.json; finding:shortest_pathscost per configuration grows with path depth (2.3 -> 6.5 ms from depth 3 to 6 on an 8-region chart). Harness noise fixed:persisted_*rows had a CV up to 47 % (GC debt between rows, SQLite rows too short); now under 7 %. The Coverage job fetches depth 2 so the budget ratchet (baselines only go down unless a note names a perf run) is enforced on every PR. - Idempotency inbox scopes are escaped (battle-test #303). Scope parts
(
principal,machine.id,instance_key) now escape%and/so the join is injective. Parts without either character are byte-identical to before, so existing rows keep their scope. If a principal, machine id or instance key in your deployment contains%or/, inbox rows written before the upgrade resolve to a different scope afterwards: their cached receipts are unreachable, a retry inside the TTL re-executes once, andinbox.forget(old_scope)no longer matches. Let the TTL window elapse before relying on deduplication for such keys, or purge the inbox at deploy time. -
A principal must identify a caller.
IdempotencyPlugin.scope_forand every web adapter (StatechartRegistry.act, the Starlette / FastAPI / Litestar / Flask / Quart HTTP helpers, DRF) route the principal throughvalidate_principal: a value that is not a non-emptystr, or is one of the literal placeholders"None"/"null"/"anonymous", is refused –ValueErrorfrom a directact()call, 401UnauthenticatedErrorfrom the HTTP helpers, 401 from DRF when anIdempotency-Keyarrives from an unauthenticated user. Previously the adaptersstr()-coerced the value, soNonebecame"None"and every anonymous caller shared one scope. Code that relied on an anonymous caller being deduplicated must authenticate it first. -
Plugin hook dispatch is ~2x cheaper (battle-test #304).
_SafePluginbuilt a freshfunctools.wrapsclosure on every hook lookup – four per event – which cProfile put at ~30 % of a plugin-equippedsend(). The guarded wrapper is now cached per hook, keyed on the underlying function so rebinding a hook on a live plugin still takes effect at once. Measured (Windows, 3.14, 10 000 sends): sync 5-plugin overhead 5.74x -> 3.14x of a bare send; async 3.88x -> 1.49x. Behaviour is unchanged. -
Deprecation targets now follow the policy (#296).
ErrorEvent.datasaid “removed in 0.9” and--stylesaid “removed in v0.8.0”, but both still work. Their warnings now name 1.0, the next major, and share the helper’s message shape (what / since / removal / alternative). They warn once per call site instead of on every access. SyncInterpreter.send_events()now applies the same admission checks assend()—strict/event_schemas(UnknownEventError/InvalidEventPayloadErrorat the call site) and the reserved-payload-key warning — and both engines’send_events()run the newon_before_sendinterception (#304). Previously a batched send on the sync engine bypassedstrictentirely.- AGENTS.md now states the real Python floor, 3.9 (it said 3.8+;
requires-pythonand CI have been 3.9 since 0.9).
Fixed
- 1.0 hardening, as battle-tested (#296). A plugin author’s day
against a marketplace of third-party distributions laid out as
installed
.dist-infos: a good audit plugin, one whose loader raises, one naming a class that is not aPluginBase, one whose constructor needs arguments, one whose module import has side effects, one raising in EVERY hook, and two distributions declaring the same entry-point name. Held:import xstate_statemachineand building / running an interpreter import NO plugin module (the side-effect file never appears,entry_points()is never called);discover()lists the loadable ones and logs the rest;strict=Trueraises on the first failure;allow=by entry-point or distribution name never imports the others; the kill-switch returns[]with nothing imported; a plugin raising in every hook never stops the interpreter and the good one still sees the transition; the 3.9 dict-shapedentry_points()shim selects by key;deprecated()emits once per call site across 16 threads × 4 calls, again from another site, and re-arms afterreset; the 1.0 checklist mechanically – every contrib__all__name in the API index (read statically, so a name behind a missing extra is caught), no open to-do markers insrc/, the shipped examples or the guide’s prose, the Python range one truth acrossrequires-python, classifiers, the CI matrix and the README,[all]covering every contrib extra (tests/test_battle_296_scenario.py,test_battle_296_a.py,test_battle_296_b.py). Found and fixed:attach_discovered()CALLED any zero-argument callable an entry point named –"os:abort"or"atexit:_run_exitfuncs"ran before anything checked it was a plugin (aPluginBasesubclass is instantiated, an instance kept, a factory must be marked with the newplugin_factorydecorator, anything else is refused unexecuted – behaviour change: an undecorated factory entry point is now skipped, or refused understrict=True); an entry point naming a non-PluginBaseclass was.use()d anyway (refused / skipped);xsm pluginshid skipped loaders – a broken plugin was simply missing from the list (aSKIPPEDrow naming the error;--jsoncarries askippedlist;discover()publisheslast_skipped/last_failed); no CI gate existed (xsm plugins --strictexits 1 naming the entry point and its distribution, no traceback);allow=did not normalise names (xsm_thirdparty_pluginvsxsm-thirdparty-plugin: PEP 503 now);XSM_DISABLE_PLUGIN_DISCOVERYignoredTRUE/on(1 / true / yes / on, any case); the skipped / failed results were module globals edited in place (concurrent or re-entrantdiscover()calls corrupted each other – collected per call, published once under a lock); the 3.9_dist_ofrescanned every installed distribution per entry point (one index per call); a distribution withoutNamemetadata raised;instrument_all( discovered=True)called twice attached a second instance of every plugin (one class per interpreter). Docs: the plugins guide’sxsm pluginssample was invented (real output with the fixture installed, SKIPPED row included, compared line by line);--jsonshape,--strictand exit codes documented; the API index listed--plainas apluginsflag and lacked rows forSkippedPlugin,last_skipped,last_failed,DISABLE_ENV,GROUPS,hooks_of,plugin_factory;attach_discoveredrefusing non-plugins,last_skipped,instrument_all(discovered=True), the stores / brokers entry-point table and thepip install -e . && xsm plugins --strictCI recipe added; the compatibility page now says it is generated and asserted and names the date and commit it was verified at (every oldest / newest pin on the page is the pin the compat workflow installs); SECURITY.md gains the SemVer statement, contrib provisional, theallow=look-alike caveat and thexsm pluginsaudit step; the README names[all]; review:allow="name"(a bare string) was iterated letter by letter and matched nothing in silence (TypeErrornow;allow=[]documented as closed); a failure before loading left a stalelast_failed; the CLI blamed--strictfor errors raised without it; the kill-switch log said=1whatever the value; class-dedupe documented. Held: a plugin blocking inon_interpreter_starthangsstart()(in-process code with full privileges, as SECURITY.md says); a plugin callinginterpreter.stop()from a hook stops it cleanly; the deprecation seen-set clears past 4,096 sites and warns again (documented); the compat workflow installs oldest and newest per extra and the[all]smoke asserts the core import is third-party-free. - The recipes pack, as battle-tested (#308). A SaaS billing day
through the recipes: 1,000 Stripe deliveries across 200 subscriptions
– forged signature, body tampered by one byte, timestamp a day old,
an hour in the future, a header with 50
v1=entries (one valid), not=, a body that is not JSON, a 2 MB body, a Stripe retry of a seenevent.id, and the sameevent.idfrom 8 threads at once: every forged / stale / tampered one is 400 and changes nothing, every retry isduplicate, the race yields exactly one transition, every subscription ends where its valid events say; FastAPI and Flask endpoints answer byte-identically; 500 slot-filling conversations under a hostile extractor (unknown slots,None,"",__class__, nested dicts, lists, 100 empty turns) never book with a missing slot and nudge exactlyMAX_NUDGEStimes; a rollout whose metrics raise / return NaN / strings /Nonenever promotes andMETRICS_BADrolls back from any stage; WebSocket backoff stays within[0, min(base·factor^n, max)], never exceedsmax_attempts, and the socket’scloseruns exactly once per connection however the state is left; apast_duedunning deadline survives the process dying and fires from aDueTimerScanner(tests/recipes/ test_battle_308_scenario.py,test_battle_308_a.py,test_battle_308_b.py). Found and fixed in the recipes: a correctly SIGNED body that is not JSON / not an object / has nodata.object.idraised out ofhandle_webhook– a 500 Stripe retries for days (400 now);ConflictErrorescaped the handler as a 500 with the exception text although the page promised a 409 (409 with a fixed body, once, for both endpoints); a non-ASCIIv1=crashedcompare_digest(bytes now, still constant time); a 400-digitt=overflowed the time arithmetic; anevent.idover 255 chars / non-ASCII was refused by the inbox AFTER verification as a 500 (400 before the store is touched); an EXPANDEDsubscriptionobject made the store keysubscription.{'id': ...}– a second record per subscription (the object’sidnow; a number / list is 400); slot values accepted dicts, lists and megabyte strings – and a bidi override / zero-width / control character was echoed into the confirmation prompt (short scalars only, format and control characters refused:valid_slot_value,MAX_SLOT_CHARS); the rollout recordedpercent/historyBEFOREapply_percentran, so a failed push claimed an exposure that was never live (recorded after success only) andhistorygrew without bound (MAX_HISTORY);queue_workers.apply_eventbeforeconfigure()died with'NoneType' object has no attribute 'load'(aRuntimeErrornamingconfigure(store)); the form-wizard Streamlit / Gradio snippets were cut mid-string on the page (a literal ``` inside the f-string closed the fence); review: a REAL load-save conflict is proven to leave no idempotency mark (the 409’d delivery’s redelivery is a first delivery, never a swallowedduplicate); U+2028 / U+2029 line separators are refused as slot values like a newline; an expandedsubscriptionobject without an id (or"") is 400 instead of a silent per-invoice record. Docs: the Stripe Guarantees box said “answer 409/500 and Stripe will redeliver” (the exact 409 body now; the window applies in both directions;MemoryInboxforgets on restart; one Stripe account per inbox – scope the principal by secret hash otherwise); Troubleshooting tables on all eight recipe pages whose error texts are checked against the code in both directions; every Guarantees / Troubleshooting claim carries a<!-- test: ... -->anchor that fails when the test disappears; WebSocket “What this does not do” (no buffering; onSyncInterpretersocket callbacks are mailbox events processed at the nextsend()/tick()); every recipe code block runs verbatim from a temp dir holding only the copied folder; everyxsm simulateline ends where the prose says; the Step Functions page’s ASL is validated (StartAt, everyNext/Catch, every state reachable) and its XState builds strict; the recipes index lists all nine with nav / search asserted. Held: a leading+int=, duplicatet=,tolerance_s=0,1e9/nan/ 10 KB / non-hexv1=(all 400); everyv1candidate compared, no early exit on length; 16 threads × 5 rounds on a default SQLite store give only 200 / 409, never “database is locked”; a non-stringevent.typeis ignored with 200;YESduringcollectingand aslots=key do nothing; 10,000 turns keep the context under 300 bytes; anon_messagecallback raising is contained and the message counted; aclose()raising still completes the disconnect; the queue retry loop stops afterRETRIES + 1and re-raises. - Comparison pages, the support-bot example and the launch kit, as
battle-tested (#291). A reader’s day: every “ours” cell of the three
agent comparison tables (
vs-langgraph,vs-burr,vs-statelyai-agent) verified against the code – compound states three deep and adone.statejoin;run_toolre-checking the per-state allow-list with thetoolAllowedguard EDITED OUT of the chart; aDueTimerScannerwaking an hour-oldafterdeadline from a SQLite store after every worker died;awaiting_humanparked with the exact pending call id and an escalation deadline; the stores, the trace’sgen_ai.*names and secret redaction,spawn_agent,statechart_node/langgraph_service/agent_tool_from_machine; the “theirs” columns re-checked against the INSTALLED LangGraph (interrupt,Send,Command,RetryPolicy,TimeoutPolicy, checkpointers – “no durable timers” and “conditional edges are code” hold), the real Burr package and the npm registry; every “ours” snippet run under-X dev -W error, withinstructorandlanggraphhidden, from a cwd outside the repo (writes nothing,refundnever runs before approval); the example’srun.py --fakeoffline with ONE refund,--rejectwith none, a missing SDK / key as one line and exit 2, four processes on one database intact, a fake key appearing in neither trace nor database; every launch draft under its DRAFT banner with no live post URL (tests/test_battle_291_scenario.py,tests/test_battle_291_a.py,tests/test_battle_291_b.py). Found and fixed: the Show HN draft’s headline snippet importedxstate_statemachine.agents(does not exist) with made-up API names – it now runs;RETRY_OUTPUTcalled a state in three drafts (it is a guarded transition); Burr is published asapache-burr0.43.0 (theburrname stopped at 0.42.0;checkedand 11 source notes updated); @statelyai/agent is maintained (a 2.0 alpha on XState 6) though 1.1.6 is still npmlatest, andzodis a dev dependency, not a dependency; four “ours” cells claimed more than the code does (“Redis / Postgres stores” → “Redis / SQLAlchemy (e.g. Postgres)”, “event log replay” → “transition-logreplay()”, “Stately editor round-trip” → “Opens in the Stately editor”, “OTel plugin” → “OTel spans (on_span=\"otel\")”), each now anchored by a test; the pages rot silently – a test fails when a competitor’scheckeddate is over 180 days old or the installed LangGraph major differs (the rule is stated in each page’s intro);run.pyignoredXSM_SUPPORT_BOT_DB(every run wrotesupport.dbinto the cwd) and crashed with aPermissionErrortraceback on an unwritable--db(oneerror:line, exit 2;--trace/XSM_SUPPORT_BOT_TRACEoverride added); review: the 180-day freshness and LangGraph-major checks were time bombs on every pull request – they are nightly-only now (XSM_COMPARISON_DRIFT=1, run by thecomparisonsCI job, which installs langgraph for it; the pages say “nightly”, not “the build”);tomllibin two scenario tests broke Python 3.9 / 3.10 (skip-guarded);run.py --db <directory>was aStoreErrortraceback (one line, exit 2); an unwritable--traceran SILENTLY with zero usage and exit 0 – the trace path is probed before the run (one line, exit 2); the example README promised “2 minutes” without a measured time (~2 s cold) and did not say what the example writes or that the SQLite snapshot holds the conversation (context.messages, prompt included); the README’s “Find your way” table had no agents row; the AI classifier was missing frompyproject.toml; the agents guide’s Compatibility section now lists each competitor’s “checked against” version from the data file. Docs tests: every repo path and/guide/<slug>/link in the launch kit resolves, everyxsm <cmd>passes--help, every TOOL_LOOP state named exists, every snippet import works and every Python snippet runs. Held: the three pages are inpages_order, searchable and cross-linked; the “when to choose” sections are substantive in both directions; a “Theirs” sketch is deliberately NOT added to the agent pages (a competitor snippet we cannot run is a claim we cannot test). - Multi-agent recipes, as battle-tested (#290). The support bot as
a SUPERVISOR: a planner hands 100 tickets to a worker region that
spawns one
TOOL_LOOPsub-agent per ticket with its OWN budget; every worker reportsAGENT_DONE/AGENT_FAILEDwith usage,total_usageis the exact sum,usage_by_agenthas 100 distinct ids, no result ever carries a conversation; X0.13 across the tree (a worker registry wider than the parent’s allow-list refused at construction, wider than the spawning state’smeta.toolsrefused at spawn, a worker’s model proposing a tool outside its own list istool_deniedin the child andAGENT_FAILEDin the parent, which carries on); a sub-agent exhausting ITS budget fails alone; the globalBudgetPlugintrips once;worker → judgeand aHANDOFFwithoutfromareReceipt.denied; the PIPELINE’s review loop endsfailedafter exactlymax_revisionsrewrites; a DEBATE whose CON debater fails still reaches the judge; async children cancelled with the parent; 1,000 spawns flat (examples/integrations/agents_support_bot/tests/ test_battle_290_scenario.py,tests/contrib/agents/ test_battle_290_a.py,tests/test_battle_290_docs.py). Found and fixed:BudgetPluginrolled usage up when the parent DEQUEUED a report – a planner handing off a batch had everyHANDOFFqueued before the firstAGENT_DONEwas processed, so all 20 workers spawned against a budget that allowed one; the rollup now lands when the child SENDS (on_before_send) andBUDGET_EXCEEDEDis queued AFTER the tripping report, so that result is still collected; the plugin de-duplicated reports byid(event)without holding the event (addresses were reused – 200 reports summed to 16 turns); a string / bool / NaN / negative limit madeexceeded()raise inside a swallowed hook (the global budget was silently never enforced –AgentConfigErrorat construction now); hostile child counts (negative, NaN,"junk") poisoned the totals (sanitised); the DEBATE chart had noAGENT_FAILEDarm, so one failing debater stalled the judge forever (a failed debater “states” its failure); a duplicateevent.idwhile that child was running replaced it in_actorsand the orphan kept spending after the parent stopped (refused); a dict task wasstr()-ed into the prompt (refused); a child chart that never runsnotifyParentwas accepted and the parent waited forever (refused); a sync model under the asyncInterpreterdropped the spawn coroutine – no child, no error (AgentConfigError);handoff_guard({"planner": "worker"})treated the string as a set of letters ("w"allowed,"worker"denied – refused at construction; matching is exact, non-string names denied);usage_by_agentgrew without bound (max_tracked_agents=10_000, oldest evicted;total_usagenever trimmed);xsm replayfed anAgentTracePluginJSONL printed one? ""row per line and exited 0 (exit 1 now, namingInspectorPlugin(JsonLinesSink)/xsm sim --record); the supervisor chart’s description claimed asendTo/raisehandoff that does not exist; review: oneinftoken count madeexceeded()raise inside the fail-open hook on every later report (the global budget was permanently disabled – non-finite counts are 0 now); a report to an already-stopped parent was still charged; the de-dup ring held up to 10,000 report payloads (transcripts) – released once the parent dequeues the report; a child chart reporting fromexit/ a transition’sactions/onDonewas wrongly refused as “never reports” (every action list is scanned); astrict: trueparent withoutBUDGET_EXCEEDEDlost the trip silently (check_budget_event_declared, run byspawn_agentinside its action, loudly); plugin de-dup state shared across threads is locked. Docs: theBudgetPluginreference described the old dequeue-time rollup; Guarantees (seven multi-agent promises, each with a test anchor, and what is NOT promised: reports that never arrive are not counted, children already running overshoot,cost_usdis what the adapter reports, an unhandledBUDGET_EXCEEDEDleaves the chartrunning), Operations (alert on theAGENT_FAILEDrate,budget_exceeded,usage_by_agentgrowth;AgentTracePlugin.totals()/parent_idname the spender), Troubleshooting rows for every provoked error (incl. “aHANDOFFwithout a task STOPS the supervisor underactionErrorPolicy: fail”), “Without a model” (xsm inspectrenders the regions;xsm simulatewalks them with stub actions; a real run replays only from an inspector recording), API index descriptions, the example README’s “Supervisor: many tickets, one budget” (tested). Held: a grandchild wider than its child is refused; a tool output shaped likeHANDOFFcannot make the parent hand off (the model never sends parent events);max_total_tokens=0trips on the first report; two parents sharing one plugin keep separate totals; a late report to a stopped parent is dropped without raising; 1,000 async children leave_actorsempty;xsm validate/inspectaccept all three charts; the pipeline withmax_revisions=0or a failing reviewer endsfailed. - pydantic-ai and structured output, as battle-tested (#289). The
support bot’s intake with a per-state schema: 100 tickets whose model
answers valid JSON, invalid JSON, JSON of the WRONG state’s schema,
prose around JSON, a 1 MB string field,
nulland a negative id – every one endsdonewith a VALIDATED value orerrorwithkind: outputafter exactlyretriesre-prompts, each counted as a turn; a pydantic-aiTestModelagent as the lookup service (usage into the budget keys, a structuredoutput_typedumped to JSON, raising →onError, hanging cancelled by the state exit, streaming deltas in order);max_turnsbeatingretries(the run endsbudget, never a hidden turn); a statechart exposed as a pydantic-aiToolthat hides the conversation and keeps the allow-list; instructor accepting prose the strict parser refuses; 1,000 runs flat (examples/integrations/agents_support_bot/tests/ test_battle_289_scenario.py,tests/contrib/agents/ test_battle_289_a.py,tests/test_battle_289_docs.py). Found and fixed: a model validator or output parser raising anything butValueError/ValidationError(TypeError,RuntimeError, aKeyErrorfrom a custom parser, instructor’s parser,RecursionErrorfrom 100k-deep JSON) escaped theoutputValidguard and STOPPED the machine withTransitionFailedError– any exception is now an invalid reply (retried, thenkind: output; the retry message names the exception type only);pydantic_ai_service/usage_logictrusted provider usage (negative counts refunded the budget, NaN raised and sent the service toonError, malformedonDonedata raised) – both reuse the budget sanitiser; a dataclass / TypedDict / dict output holdingdatetime/bytes/Decimalbrokeget_snapshot()(JSON-safe via pydantic now); the documented per-state recipe was not per-state – one schema on one state – it is a real 3-state chart with threemeta.output_models (an address sent while collecting the name is rejected and retried); the streaming service’s LASTSTREAMevent carries{"output", "usage"}(undocumented – a deltas-only handler broke on it); forgettingusage_logic()/recordAgentUsagemeant budgets never tripped (said loudly now, with the note thatonErrorspends nothing); exhaustion hid the reason (kind: outputnow names the last validation detail – field names and messages, never the model’s values); pydantic’s defaultextra="ignore"silently DROPS unknown fields – “illegal fields rejected by state” needsextra="forbid"on the model (documented; the recipe sets it);PYDANTIC_AI_TESTED(>=0.8,<3) and a warn-onlycheck_pydantic_ai_version()mirror the LangGraph gate (pydantic-ai churns); review: afield_validatorraisingValueError(f"bad {v}")and adict-typed field’slocboth echoed the reply’s values into the retry prompt andcontext["error"](custom-validator messages are now their error type, non-fieldlocparts*); a validator flipping between calls made the exhaustion message carry the VALIDATED value (never now); afield_serializerraising escaped the guard (inside the try now); non-UTF-8 bytes and unknown classes in a service’s output brokeget_snapshot()(base64 /repr); a SYNC runner behindagent_tool_from_machineblocked the outer agent’s event loop (daemon thread now);usage_logicraised on junkturnsand kept a rawdatetimeoutput. Docs: Guarantees (validation beforedone; constraints enforced not suggested; retries are counted turns; strict parser unless instructor; exhaustion never writesresult; a model can still lie inside a valid schema), Troubleshooting rows for every provoked error, an Operations alert on the output-retry rate, the example README’s “Structured intake”. Held: a"module:Model"naming a non-pydantic class / function / missing attribute isAgentConfigError; the retry message never echoes the rejected reply;retries=2, max_turns=3reachesdoneandmax_turns=2endserror(no off-by-one); a stream failing midway delivers its deltas thenonErrorwith the context closed; the service underSyncInterpreterfails loudly; async and sync runners agree on four scripts. - LangGraph interop, as battle-tested (#288). The support bot’s
TOOL_LOOPdropped INTO a 3-node LangGraph graph as astatechart_node: 100 checkpointed threads (MemorySaver) each driven in two separateinvoke()calls with a process “restart” in between – byte-identical snapshot round trips,route_by_statechartpicking the edge from the active state, the refund gate parking inawaiting_humanand resuming on the approval, one refund each, a second approval a no-op; a FORGED graph-state snapshot (approval flag flipped, the tool renamed, the amount a string) cannot run a tool (the node re-entersrun_tool); another machine’s snapshot refused with a typed error;langgraph_servicerunning a graph that raises, hangs and is cancelled by a state exit, or returns a huge output;LangChainCallbackPluginnever leaking secrets; 1,000 node calls flat (examples/integrations/agents_support_bot/tests/ test_battle_288_scenario.py,tests/contrib/agents/ test_battle_288_{a,integrator}.py, the example’stest_readme_langgraph.py). Found and fixed: a graph that calledinterrupt()was read as DONE –ainvokereturns normally with the partial state and an__interrupt__key, so the chart sawonDonefor a graph that is paused and asking a human;langgraph_servicenow surfaces it asonErrorwithGraphInterruptedError(.interrupts,.state; resume the graph withCommand(resume=...)once the chart has decided;on_interrupt="done"keeps the old shape);route_by_statechartnever matched a mapping key naming a PARENT state ("g.a"for a leaf atg.a.b) although the docs promised it – the router walks from the leaf up, most specific key wins, and with parallel regions the choice is deterministic (sorted ids); a list where the snapshot should be crashed the router with a bareAttributeError(AgentConfigError); the STREAM chunk docs pointed atevent.data["data"](it isevent.data) and omittedstream_mode=/config=. Docs: the “human approval gate” recipe is now a runnable 3-node example on a checkpointer (resumes withHUMAN_APPROVED, refunds once); the Guarantees box states the node’s contract (X0.13 preserved; plain JSON snapshot; ONE macrostep per call soaftertimers never fire inside a node –DueTimerScanneror an explicit event; two runs on onethread_idare last-write-wins at the checkpointer, serialise per thread); Threat model (graph state is untrusted input, a graph’s output is untrusted output); Troubleshooting rows for every LangGraph-visible error; the compatibility table says langgraph is NOT pinned by any extra and records the decision “in-tree soft import”;docs/api/index.mdlisted 0 of the module’s public names (all listed); the example README gains “Drop the bot into a LangGraph graph” (tested). Held: 200 node calls across 8 threads on differentthread_ids; an edited chart between invocations isSnapshotDriftError; a non-event fromevent_from_stateisInvalidEventError;input_from/output_toraising →onError; a LangChain message in a STREAM payload snapshots; a raising callback handler is contained; the version guard readsimportlib.metadata(langgraph has no__version__). [agents], as battle-tested (#287). Theagents_support_botexample on a support day: 200 tickets through two bot replicas (half refunds parked for a human, lookups, hostile and broken ones); the provider raising, returning garbage, hanging pastmodel_timeout_s, naming unknown tools, sending the wrong argument types and 50 tool calls in one turn; a restart followed by the SAME approval from two replicas at once; a human who never comes; prompt injection, a 1 MB prompt, refund amounts of -1 / 1012 /"all"; a 1,000-ticket soak (examples/integrations/agents_support_bot/tests/ test_battle_287_scenario.py,tests/contrib/agents/ test_battle_287_{scenario_unit,a,b}.py). Found and fixed – X0.13 gaps first: **tool arguments were validated only inrun_tool, AFTER the human gate – a reviewer was asked to approverefund_order(amount_cents="all"), a call that could never run (thetoolAllowedguard validates against the tool’s schema first; the error names the field, never the value); value ranges declared asAnnotated[int, Field(gt=0, le=...)]were silently dropped (type hints were read withoutinclude_extras), so the obvious way to refuse a -1-cent refund did nothing; a model that reused an earlier tool-call id could get a replayedHUMAN_APPROVEDto approve a DIFFERENT side-effect call (any id already in the conversation is a duplicate: denied); provider usage numbers were trusted – negative tokens or cost REFUNDED the budget (input_tokens=-1000bought ~65 extra turns undermax_tokens=20) and a NaN cost made every later total NaN (negatives spend 0, NaN / inf / garbage exhaust the budget,Usage.from_dictno longer crashes on"nan");Budgetlimits were never validated ("20"read as exhausted,Truewas a turn limit of 1, NaN refused every turn;AgentConfigErrornow, including unknown keys); toolargumentsthat were a list of pairs were coerced into an object and the tool RAN on input that was never an object, a string / int argument crasheddenyToolmid-transition, 1,500-deep arguments raisedRecursionErrorand were retried and billed again (alltool_denied); a bad"module:Model"output_modelleaked a rawModuleNotFoundError; a hung SYNC model underrun_agentcould not be timed out (model_timeout_swaited inline; it now runs on a worker thread and the hung call is abandoned – documented) andrun_agent(timeout_s=)bounded only the final wait, not the sends; the Anthropic adapter ran a tool with no parameters on a malformedinput; both adapters could send a tool result whose proposing turnmax_messageshad trimmed (the provider APIs reject that) – and, from the independent review, the window is now cut at a TURN boundary so no orphan is produced at all (the model never loses a result it needs); a hung SYNC model used the loop’s default executor, whose few workers a stuck call pins and whichasyncio.runwaits for on exit (a daemon thread per call now, abandoned on timeout);FakeModelids collided after a store resume (call_{n}_{k}→ unique per instance) and the duplicate-id check now also remembers ids from trimmed turns (context.spent_call_ids); a validation failure other thanToolDeniedErrorescapeddenyTooland leftpending_tool_callsstale;"1.5"token counts read as garbage. Also:core.pywas 937 lines (split intobudgets.py,_output.py,_allowlist.py); the[agents]CI cell never ran the example’s tests (gated on the FastAPI stack by mistake) andG8_agents.pytested the main checkout’s install instead of the worktree. Docs: the agents guide names every public symbol, its Guarantees box states exactly what X0.13 gives (args checked before the gate; value ranges are the tool’s job –Field(gt=0)example; a side effect aftertimeout_smay still land; a sync model’s hung call is abandoned), gains an Operations section (oneDueTimerScannerper store forawaiting_humandeadlines – a plain reload re-arms the timer, it does not fire it; what to alert on; sizing; trace rotation) and Troubleshooting for every provoked error; the example gainsops.py(open/pending/approve/reject/scan, each its own process on onesupport.db; a second approval exits 2) and an “Operate it” README section, tested. Held: tool names differing in case / trailing space / fullwidth Unicode are denied; partial, extra or duplicatedcall_idsare refused;HUMAN_APPROVEDoutsideawaiting_humanis ignored;"*"means registered tools only; the system prompt and the original task survivemax_messagestrimming;sk-.../ Bearer values are scrubbed from tool results; no leak over 2,000 runs.- Sagas, choreography and AsyncAPI, as battle-tested (#295). 90
SagaBuildersagas PERSISTED and driven through the bus (theInboundDispatcher+ outbox the example runs), a third of them failing at reserve / charge / ship: every one compensates in REVERSE order exactly once with its results in context, the step events the saga published are the channels its AsyncAPI document names; the process killed -9 mid-compensation (the compensation ran, the snapshot did not land) restarts the saga and runs the compensation AGAIN – compensations are idempotent by contract, now stated in the Guarantees box; a compensation that itself fails parks incompensationFailed(FINAL – a retry loop there would hammer a down service) with a chart-state dead letter as the operator’s ticket, and a redelivered command does nothing to it; a redelivered START and a shared bus with foreign and unknown types; the example’s charts’ AsyncAPI validates offline, names every published type and every consumed event and nothing else, andxsm asyncapi --validateprints the same (examples/integrations/eda_fulfilment/tests/ test_battle_295_scenario.py,tests/patterns/test_battle_295_{a, b_saga_input}.py,tests/eda/test_battle_295_a_asyncapi.py,tests/tests_cli/test_battle_295_b.py). Found and fixed: the START event’s payload was silently dropped –send("START", order=...)moved the saga tostepsand no service could see the data (the generated chart now runssagaStart, which copies it tocontext.input– present in both chart shapes; ⚠️ this changes the structure hash of a saga built withstart_event, so a snapshot saved from the previous builder needsverify_machine_hash=Falseonce – the guide’s Guarantees box says how;timeout_msaccepts any integral number such as5000.0, as before);asyncapi_documentsilently LOST a message when two event names reduced to the same component key ("GO NOW"and"GO_NOW"; colliding keys get a numeric suffix, one message per published type); the missing-jsonschemahint named an[asyncapi]extra that does not exist (pip install jsonschema);xsm asyncapianswered an unwritable-o,--validatewithout jsonschema, a schema-rejected document and an empty--server/--inbound/--outboundwith tracebacks or silence (one line, exit 2);SagaBuilder.step()accepted input that broke later somewhere else –retry=3(AttributeErrorfromlogic()),timeout_ms=True(a 1 ms timeout),timeout_ms="abc", an emptyinvoke, a step namedaRetryingsilently overwritten by stepa’s generated retry state, an engine-reservedstart_event– allValueErrorat declaration;ChoreographyRouter.run_until_quiet*(max_rounds=0)raised “did not settle in 0 rounds” (ValueError; both engines give the same message). Docs: the EDA guide gains saga Guarantees (reverse once each, idempotent compensations,compensationFailedfinal + dead letter,context.input,OptimisticLockre-runs a step service on a lost save race – usePessimisticLockor idempotent services), a Threat-model paragraph on why a forgeddone.invoke.*envelope from the bus cannot complete an internal invoke, Troubleshooting rows for every saga / choreography / AsyncAPI error, a saga Operations section, and a Reference that namesSagaStepandRoute;asyncapi_documentsplit into helpers (byte-identical output); the example README gains “Sagas” and thexsm asyncapicommand (tested). Held: timeout → retry → timeout → compensation onSimulatedClockcounts every service call once; START sent twice is ignored; a forgeddone.invoke.*throughsend()or the bus never moves a step; the samecausationidacross two dispatcher runs is a duplicate with both inboxes; 50 sagas × 2 replicas underPessimisticLockrun each service exactly 50 times; the 103-chart corpus produces documents that validate and are byte-identical on a second run; 10k sync saga runs show no growth. [celery], as battle-tested (#292). The Celery bridge on a fulfilment day: 100 orders through@statechart_taskas two interleaved delivery streams with DUPLICATES (acks_late); a worker killed -9 after the step ran and before the save; a shipping result landing via thetask_successsignal after the instance was saved and discarded, viapoll_resultswhen the signal was lost, and refused when it arrives again; TWO Beat processes scanning the same deadlines; forgedxsm_store_key/xsm_invocation_idheaders and a pickle-accepting app; twooutbox_relay_tasks on one outbox (examples/ integrations/eda_fulfilment/tests/test_battle_292_scenario.py,tests/contrib/celery/test_battle_292_{scenario_unit,a,b}.py). Found and fixed: Celery tasks areshared=Trueby default – aDurableTimerScheduler,outbox_relay_taskor@statechart_taskregistered on one app was RE-CREATED on every app built later in the process with the FIRST app’s closure (its store, machine, outbox): a secondFulfilmentApp’s Beat scan ran against the first app’s database and reported 0 woken –register_taskregistersshared=Falseand refuses a taken name withInvalidConfigError(plainapp.tasksilently returns the existing task; passname=for two on one app); a contendedPessimisticLockfailed a@statechart_taskon the first attempt (LockTimeoutErroris now retried withConflictError); one instance whoseload/machine_for_key/ delivery raised stopped the wholepoll_resultsscan (per-key isolation now), and a parked early completion whose delivery failed was lost with every one after it (re-parked); a stale or forged completion still openedpersisted()and SAVED an unchanged instance with a new version (10,000 forged signals = 10,000 writes andConflictErrors for real writers) – refused without a load or write;relay_once_syncon a broker whose plainpublishreturned a coroutine marked the row sent without awaiting it (the message was lost) –TypeError, row released;register_taskon an app created withautofinalize=Falseraised “Contract breach”;DurableTimerSchedulerandoutbox_relay_taskdid not refuse a pickle-accepting app (every entry point does now);MemoryPendingResultsgrew without bound on forged keys (PENDING_MAX_ITEMS10,000, oldest evicted and logged); the example hard-codedmemory://so a real worker could never run it (CELERY_BROKER_URL/CELERY_RESULT_BACKEND); and from the independent review: on anautofinalize=Falseapp a registration is aPromiseProxy– the duplicate check saw nothing, andfinalize()would have kept whichever task it built first (register_tasknow remembers the names it claimed per app and refuses a@shared_taskof the same name too), andoutbox_relay_tasksetting the closer on the proxy raised “Contract breach” (close_relay_loop(app, name); the closer is attached afterfinalize()); a completion whose record names another task is PARKED when a pending table is given (a re-entered invoke’s new task finishing before its record is saved looked stale –poll_resultssettles it), andMemoryPendingResults(max_items=0)/ttl_s<=0are refused. Docs: the Celery guide gains aregister_tasksection (including the factory-built worker app case), an Operations section (ONE Beat and its three schedules, the per-processcache+memory://trap,afterprecision vs the scan interval, tunables, what to alert on, sizing), a complete Guarantees box (exactly-once PROCESSING is not given – a duplicate task delivery runsfnagain; use an inbox), the six pickle-refusing entry points in the Threat model, and Troubleshooting rows for every operator-visible error including Celery’s own “Never call result.get() within a task” (a module-global flag that eager tasks run from several THREADS leave stuck – the scenario hit it); the example README gains “Operate it: Celery” with a real in-process worker test. Held: 8 concurrent@statechart_tasksends on one key under a REAL worker end at n=8; 4 threads delivering the same completion apply it once; a real key with another machine’s task id is refused; 500 live-watcher start/stop cycles leak no threads;disconnect()is idempotent and foreign tasks’ signals are ignored.- The broker adapters, as battle-tested (#294). Redis Streams,
Kafka, RabbitMQ, NATS JetStream and SQS on a fulfilment day, each on
its offline stand-in (the same adapter classes a live deployment
runs): 300 orders across ten subjects with duplicates interleaved; the
broker going AWAY mid-run (
sendandfetchraising for a while); two consumers on one topic; a hostile producer putting an oversized body, non-JSON bytes and a credential-bearing extension straight on the native transport; a 2,000-round-trip soak (examples/ integrations/eda_fulfilment/tests/test_battle_294_scenario.py,tests/contrib/brokers/test_battle_294_{a,repr,integrator}.py). Found and fixed: a producer could forge the attempt count – the adapter kept the larger of the wire’sxsmattemptand the broker’s own redelivery count, soxsmattempt=10**9dead-lettered a healthy message on its first transient failure; the attempt is now the BROKER’s count plus local requeues (Kafka, which keeps no count, starts again at 0 after a consumer restart – documented); a transport reporting a malformed redelivery count (None, negative, a string) raised mid-decode and stranded the whole fetched batch un-acked – coerced to a first delivery; a raisingon_reconnectlost the batch it fired after, a raisingon_disconnectmasked the transport’s own error, a raising dead-letterput/on_undecodablestopped the good messages behind a bad one – all logged, never propagated; the health flip happened outside the lock, so several threads firedon_disconnect/on_reconnectmore than once per real transition;KafkaBroker(sasl_plain_password=...)died withTypeError: _Core.__init__()(unknown options are named with a hint atclient_kw=, the value never echoed);xsm asyncapi --protocol bogusexited 0 (the protocol is one of the AsyncAPI binding names);xsm dlqon a missing store file said “xsm snapshots: error:”. Docs that were false or silent: per-subject order holds only while ONE consumer handles a subject – Kafka and SQS FIFO guarantee that, competing consumers on one Redis stream / RabbitMQ queue / NATS durable do NOT (the ways around it are listed); NATS and SQS FIFO dedup a republished envelope AT THE BROKER (Nats-Msg-Id,MessageDeduplicationId), so the inbox reports no duplicate there – do not alert on zero;batchdefaults are 10 (not 100) on Redis/NATS; the Redis shard hash is crc32; the Reference was missing 9 of 18 public names; an Operations section (reconnect visibility, what to alert on, each broker’s redelivery knob and sizing) and a Troubleshooting row for every operator-visible error (MissingExtraErrorper extra,BUSYGROUP,NOGROUP,QueueDoesNotExist, the local hold release, dedup windows swallowing a replay, Kafka uncommitted offsets); the example README gains “Operate it: when the broker goes away” and every command in its broker table runs in the suite. Held:healthy/on_disconnect/on_reconnectfire once per transition and the outbox keeps its rows through an outage; nothing is published twice; hostile bodies are dead-lettered ascorruptwithout the body and the good messages behind them flow; settle-once, head requeue order, Kafka commits only the contiguous settled prefix; no adapterrepror failure log shows a URL or credential; flat memory over 10,000 round trips. - The EDA core, as battle-tested (#293). A fulfilment day in the
eda_fulfilmentexample as an operations team lives it: 1,000 orders through TWO service replicas on one SQLite file and one bus, with duplicates and poison interleaved; the relay killed -9 after the broker accepted a row and before it was marked sent; a consumer killed -9 after the snapshot save and before the outbox flush;xsm dlq list / show / replay / purgeend to end against the dead letters the day produced; a dirty bus (credential-bearing extensions, non-objectdata, a badtraceparent, a non-stringtype); a 10,000-envelope soak (examples/integrations/eda_fulfilment/tests/ test_battle_293_scenario.py,tests/eda/test_battle_293_a.py,tests/persistence/test_battle_293_a_outbox_kill.py,tests/tests_cli/ test_battle_293_b_dlq.py). Found and fixed: two relays draining one outbox published every row twice –OutboxRelaynow LEASES the rows it publishes (claim()/release()onSQLiteOutboxStore,SQLAlchemyOutboxStore–FOR UPDATE SKIP LOCKEDon Postgres – andMemoryOutboxStore;OutboxRelay(owner=, lease_s=),DEFAULT_CLAIM_LEASE_S30 s; a relay that RAISES hands its unsent rows back at once, a relay that DIES loses its lease; still at-least-once, never N-times-once; existing SQLite tables gain the two columns on open, SQLAlchemy users run a migration –sqlalchemy_ordersships it as0003;outbox_relay_task(owner=, lease_s=)); a usermachine_for_type/key_forthat raised was requeued FOREVER (the attempt counter only counted failures inside the machine) – counted now, dead-lettered asmax_attemptsat the limit, requeued (never acked unrecorded) when the dead-letter store itself is down;Envelope.from_jsonon input nested too deeply to parse letRecursionErrorescape into the consumer loop (dead-lettered ascorruptnow;to_jsonon self-referencing / extremely deepdataraisesEnvelopeCorruptErrortoo) – and the independent review found the gap behind it: a payload nested ~700 deep PARSED but neither the redactor nor the dead-letter writer could walk it, so it could be neither processed nor recorded and was redelivered forever;MAX_DATA_DEPTH(64) is now enforced on decode and a dead-letter record is ALWAYS written (a payload the redactor cannot walk is replaced by the envelope’s id / type / source);NaN/Infinityare refused as not-JSON; a state-store / inbox OUTAGE (StoreError) is never counted as poison – the message keeps being redelivered, only user-hook failures count; the SQLAlchemy outbox on an upgraded 0.11.0 table would have died at the first relay tick (create_alladds no columns) –SQLAlchemyOutboxStoreaddsclaimed_by/claimed_untilon open withcreate_table=Trueand refuses to start naming the migration without it (LEASE_COLUMNS); itsclaim()is now the guard on EVERY dialect (the UPDATE re-states “free or mine”, each relay reads back only its own stamps;SKIP LOCKEDis an optimisation), andrelease()runs even whenmark_sentraises;xsm dlqandxsm asyncapianswered operator mistakes with tracebacks – a missing or non-SQLite--dlq/--storefile (which also silently CREATED an empty database and reported “no dead letters”), a missing or invalid--machineJSON, an--logicmodule that cannot be imported, a bad--older-than/--limit, an unknown id – all one line and exit 2 now (exit 1 is reserved for “the replay ran and the machine refused it”); the example README’sxsm dlq replay --logic logicdied onshipOrder(the module exposesship_order). Docs: the EDA guide gains “Several relays on one outbox (leases)”, an Operations section (sizing, lease tuning, what to alert on), a Troubleshooting table covering every dead-letter reason,EnvelopeCorruptErrormessage andReplayRefusedErrorcase, and the Reference names every public symbol; the SQLAlchemy guide notes theclaimed_by/claimed_untilmigration;cli.mdlists the exit codes; the example README gains “Operate it” with the exact commands (tested). Held: per-subject order undermax_in_flightwith both locks, threads on one sync dispatcher, lease takeover across processes, the outbox kill -9 windows (snapshot and rows always agree), dead-letter redaction, replay racing a live dispatcher,new_idmonotonic across threads, the size cap inclusive with multi-byte UTF-8, no growth on the dispatcher / outbox hot paths. - Examples and comparisons, as battle-tested (#286). The six example
apps as a newcomer has them – each README’s own
pip installline run against the built wheel in a FRESH venv (3.9 and current), the app copied OUT of the repository, its suite run with noPYTHONPATH; and the seven comparison pages against the competitors’ CURRENT releases, with probes that re-run on the pinned versions (tests/ test_battle_286_{scenario,a,b}.py). Found and fixed: three example suites assumed the repository checkout around them (the sibling fastapi example, the docs tree, the 104-chart corpus) and failed for anyone who copied one app (they skip those checks outside the checkout);agents_support_bot --providerwithout the SDK or key was a traceback (exit 2 + one line naming the fix) and--rejectclaimed a refund that never ran; five READMEs linked../../../docs/...(dead on GitHub / PyPI / a copied folder – absolute URLs now);sqlalchemy_ordersdid not install on Python 3.9 / Windows (greenlet has no wheel there –--only-binary greenletdocumented);django_approvalsstates its Django 4.2+ / Python floor and no longer leavesapprovals.sqlite3.testbehind; the example runner fails if any suite leaves files behind. Comparison claims that were false or stale on the current releases: django-fsm-2’s admin isdjango_fsm.admin.FSMAdminMixin(fsm_adminis deprecated);transitions.LockedMachineusesthreading.Lock, transitions has NO history states, and both transitions and python-statemachine export Mermaid without Graphviz; LangGraph’s products are LangSmith Studio / Deployment and nodes taketimeout=; AWS marks Step Functions Local unsupported (TestStateis the route); the README’s “Find your way” table linked 3 of the 7 pages. Every competitor now carries a “checked” release (langgraph 1.2.14, burr 0.42.0, @statelyai/agent 1.1.6, the AWS guide’s read date) and every row a source, both pinned bytests/test_comparisons.py; PyPI drift fails the pin. The competitor benchmark’s ratios reproduce within tolerance (a dated reproduction note in its README). xsm_migrate_fsm, as battle-tested (#310). Migrating a real django-fsm-2 app with the site up – 10k rows in batches in bounded time and memory with every column correct,--dry-runtouching no row, dirty data, live writers firing@transitions during the migration, the dual-read window in both directions, the recipe run word for word,scripts/verify/django_fsm_migration.py(tests/contrib/django/test_battle_310_{scenario,a,b}.py,test_battle_310_postgres.py). Found and fixed: a column value the chart did not know (a renamed legacy state, a typo, an empty string, NULL) killed the run with a traceback on row 4,217, half-migrated – such rows are skipped and REPORTED per value,--map OLD=NEWfolds renames in (malformed / contradictory maps and a map to an unknown state are refused up front; a map no row matches is flagged as a typo), and the summary says skipped rows count toward “remaining”; 36k unknown rows then hit SQLite’s parameter limit (the run now walks pks forward instead of excluding every skipped one); a row a live writer moved between the read and the UPDATE was migrated into the state it had already LEFT (the UPDATE is fenced on the FSM value too); an FSM@transitionby still-deployed old code moved the column but the snapshot stayed stale, so the nextsend()started from the wrong state (FSMDualWriteMixin.save()re-adopts the snapshot at the column’s value, fenced on version AND column so a stale instance never rolls back a newersend(); 8 threads mixing both paths on one row end consistent); FSM values that are not valid XState keys ("in-progress"vs"in_progress","état",FSMIntegerField) merged or were garbled – the chart recordsmeta.fsm_valueand both directions map back exactly (regenerate pre-0.11.0 charts with--write-chart);RETURN_VALUE/GET_STATEtargets produced a fake state whose name changed every run (now one guarded transition per allowed state,<method>Returns+params.value; unbounded ones refused);on_errortargets were dropped (now states,meta.on_error;custominmeta.custom); every lambda guard was namedlambdaand same-named conditions from different modules merged (stable, distinct names); acontext()hook raising for one row was an unnamed traceback (the pk is named;failed=skips it and the command lists them); rows adopted into a state with anaftertimer got no scanner deadline (from_state_ids(timers=True, clock=));--dry-runprinted the recipe after the JSON so| python -m json.toolfailed (stdout is the chart only, the recipe goes to stderr); an unknown--database, a wrong--statechart-field, an unwritable--write-chart, an unmigrated database and--batch 0were tracebacks (clean errors, exit 1);--fieldnames the model’s FSMFields. Added:xsm gt --from-django-fsm app.Model [--fsm-field F]extracts the chart from the decorators withoutmanage.pyor a database and generates code from it. Docs: the migration recipe says what the chart records, that the migration is safe with the site up and that the window is two-way; the command table,cli.mdand the API index updated.- Django REST Framework and Channels, as battle-tested (#283). The
API and the WebSocket under a company’s load – 200 API clients
approving with every request retried under an
Idempotency-Key, the 401 / 403 / 409 / 422 matrix per role, the OpenAPI schema, a hundred subscribers on one expense each receiving every transition once, auth / origin refusals, the example’sconfig.asgiunder a real uvicorn and daphne driven end to end – on SQLite and Postgres (examples/integrations/django_approvals/tests/test_battle_283_ scenario.py,tests/contrib/drf/test_battle_283_a.py,tests/contrib/channels/test_battle_283_b.py,tests/test_battle_283_send_ordering.py). Found and fixed – in the CORE: the sync engine refused a send to a stopped / finished machine BEFORE theon_before_sendinterceptors ran, so a replayedIdempotency-Keygot a 409InterpreterStoppedErrorinstead of its original receipt once the approval had finished; it now intercepts first, like the async engine; a fresh key sent to a finished machine then stayed “in flight” for the inbox TTL (every retry 409) –IdempotencyPluginreleases its claim when the engine drops the event (both engines);send_events()on a stopped machine dropped the batch silently – it now intercepts and fireson_event_droppedper event likesend()(both engines). DRF: a replay’s body described the row’s CURRENT state, not the original receipt’s (state/state_idsnow come from the receipt viavalue_from_ids;contextis the row’s current, documented);X-XSM-Reasonheader carries the audit reason (reasonis a reserved payload key); the schema documentsIdempotency-Key/X-XSM-Reasonand 401 / 503, and typesStatechartSerializerFieldas the state body. Channels: a transition committed in the admin, through the REST API or a command never reached WebSocket subscribers –contrib.channels.broadcast(apost_transition(on_commit=True)receiver) pushes committed transitions from anywhere, scheduling on a running loop when there is one, and the consumer no longer pushes its own; underAuthMiddlewareStackthe scope user is aSimpleLazyObjectand the per-push re-authorisation raised, closing EVERY subscriber 1008 on the first transition (the concrete model is resolved through the proxy); a malformed JSON or binary frame killed the consumer with 1011 and left it in its group (now a 422 error frame, socket open); a consumer overridinggroup_namereceived no broadcasts (register_group_namer/group_names_for; a plain instance-method override still imports and is addressed when it does not needself); a missingAuthMiddlewareStackis logged as a warning. Not ours: daphne on Windows stalls authenticated DRF requests (uvicorn serves the same app; documented). Docs: DRF/Channels guide gains where pushes come from, error frames and close codes, origins with a separate-frontend recipe, thefilter_backends/get_object()gotcha, the idempotency semantics; API index, extras row and the example README (status page updates from any source) updated. - Django admin and management commands, as battle-tested (#282).
An ops team’s day – fifty reviewers in the admin at once seeing only
their role’s buttons and never a 500, a 200-row bulk action reporting
exact counts, the confirm form’s reason and tampered event names, the
changelist / state filter / search /
TransitionLogadmin at 10k rows in bounded queries and time,xsm_snapshots/xsm_refresh_columns/xsm_deadlines/xsm_inspect/xsm_diagramon 10k rows in bounded time and memory,--plainon a cp1252 console – on SQLite and Postgres; the scenario itself held (examples/integrations/ django_approvals/tests/test_battle_282_scenario.py,tests/contrib/django/test_battle_282_{a,b}.py). Found and fixed: the confirm page was a 200 for staff with NO permission on the model (naming the object and the event); a missing pk was 403 for viewers (now 404; 403 only for users who may not view the model, so pks cannot be probed); view-only users were offered every transition in the bulk-action dropdown; aLockTimeoutError/ConflictErrormid-bulk was counted as “denied” (now “n failed (retry them)” at error level, the other rows still applied; the selection is streamed); the diagram’s highlighting took state ids from the row unchecked (a tampered column could inject Mermaid directives – now only known, Mermaid-safe keys). Commands: a malformed / missing pk, an unknown--database, an unwritable-o, or a model with severalStatechartFields and nostatechart_field_namewere tracebacks (nowCommandError, exit 1, checked BEFORE any output);xsm_snapshotsandxsm_inspect <pk>accept--database;--limit < 1refused;--jsonkeeps non-ASCII. Docs: §Admin redrawn (inline before Save, buttons after) with every setting, URL name, the 403/404 rule, the bulk message, the 2000-char reason cap and the CSP-safe emptyxsm_mermaid_scriptblock (block names pinned by test); the command table matches--help(pinned); amanage.pysection in the CLI guide; the example README’s Celery line namesDurableTimerScheduler+xsm_deadlines_everyas they exist. - Django signals, audit, permissions and outbox, as battle-tested
(#281). The trail that cannot disagree with the state, under a
finance team’s load – 200 expenses approved by two roles from separate
connections with a gapless, attributed audit trail (refusals included,
to_statesagreeing with the row), apre_transitionveto that writes nothing, receivers that raise (in-transaction: the send rolls back;on_commit: the send is kept and the error surfaces), 1000 users getting exactly their role’s events at bounded query cost and never a forbidden one under concurrency, the outbox row committing WITH the approval and a relay crash re-sending the same envelope id,forget_statechart()redacting while keeping the chain – on SQLite and Postgres (examples/integrations/django_approvals/tests/ test_battle_281_scenario.py,tests/contrib/django/test_battle_281_ {a,b}.py). Found and fixed: the outbox / inbox plugins wrote fromon_event_processedINSIDE the engine’s plugin containment, so a failing outbox INSERT was logged and the approval committed WITHOUT its integration event –send()now buffers marker plugins and flushes them after the fenced UPDATE, unwrapped, likepersisted()(_markers.py; a plugin instance shared across concurrent sends keeps buffering until the last one exits; a store on another database alias than the row is refused);post_transition.disconnect(fn)afterconnect(fn, on_commit=True)silently did nothing (the receiver fired for the rest of the process) – every spelling disconnects now, incl. bound methods and per-sender connections, andon_commitreceivers honourweak=like Django’s;post_transition’sto_stateswas wrong when a parallel region finished in the same send; engine-generated audit rows (done.state,after) borrowed the human sender’s actor; a user model with a UUID primary key broke every audited send (TypeError: UUID is not JSON serializable); a send against a FINISHED machine left no audit row (nowunhandled, with actor, reason and error);has_event_permission/permitted_events/ the admin buttons required EVERY permission guard named on an event – anorguard or two alternative transitions guarded by different roles hid the button and DRF answered 403 for a user who couldsend(); an auth backend whosehas_permraises was a 500 (now a logged denial); the admin transition button was a 500 onLockTimeoutError/ConflictError(now a message);TransitionLoggained a read-onlyTransitionLogAdmin(filters by disposition / event / model / actor; nobody – superusers included – can add, change or delete a row;XSM_ADMIN_TRANSITIONLOG = Falseopts out). Docs: Signals / Audit / Permissions (403 vs 409 table) / Outbox sections rewritten with one end-to-end runnable block; the example gainsmanage.py relay_outboxand its README says honestly what is wired. - Django integration (field, mixin, store), as battle-tested (#280).
The approvals app under a finance team’s load – 200 expenses with
legal and finance racing from separate connections (pessimistic and
optimistic), 16 simultaneous identical approvals applying once, a
rollback that undoes snapshot + columns + deadline + audit row + the
on_commitsignal together, 200 escalations woken once by two concurrentxsm_deadlinesruns,migratefrom zero +makemigrations --check, two reviewers posting the admin form at once – on SQLite and on Postgres 16 (a testcontainer; CI’s Linux[django]cell runs it) (examples/integrations/django_approvals/tests/ test_battle_280_scenario.py,tests/contrib/django/test_battle_280_ {postgres,a,b}.py). Found and fixed: lock errors were any message containing “locked”/”busy” (a table namedlocked_ordersbecame a retryableLockTimeoutError) – now the SQLSTATE (55P03 / 40P01 / 40001) or an anchored phrase, so a Postgres deadlock is retried bysend_with_retryandLockTimeoutError.timeoutreports Postgres’lock_timeout; a row deleted mid-send()raisedConflictErrorand was retried to exhaustion (nowDoesNotExist);lock="none"could move<field>_versionBACKWARDS from a stale instance (reopening the optimistic fence);StatechartField(null=False)/blank=Falsedid not round-tripdeconstruct()(the column always came out nullable; a project that passed them explicitly should expect oneAlterFieldmigration);refresh_statechart_columnslostmigrate=edits made in place and rewrote every row (deep copy, unchanged rows skipped,dry_run=;refresh_statechart_columns_op(batch=, migrate=)); newmanage.py xsm_refresh_columns app.Model [--batch N] [--dry-run];xsm_deadlines --foreverstops cleanly on SIGINT/SIGTERM and an unmigrated database / bad--limit/--intervalareCommandErrors, not tracebacks; an admin transition POST from a user without change permission is 403 (was a friendly redirect) and the history inline is visible to anyone who may view the row (it asked for a permission nobody grants) and capped atTransitionLogInline.max_rows(50; a 1000-row history made a 1.9 MB page). The example honoursDATABASE_URL/APPROVALS_DB, its test database is a file (Django’s shared-cache:memory:made threads hit “database table is locked”), its README commands run as written from a fresh copy, and the guide documents lock errors by SQLSTATE,DoesNotExiston a deleted row, theQuerySet.update()footgun, renaming aStatechartField(fiveRenameFields) and the real deadline table name. - Flask integration, as battle-tested (#285). The onboarding wizard
as browsers use it – a 50-POST double-click on one wizard, fifty
browsers walking every step at once on one SQLite file, two apps from
one extension, back-button form replay, a stale cookie, the cookie cap,
CSRF, a real
flask runsubprocess driven with httpx (+flask xsm inspect), the Quart shim with concurrent clients, 16 SSE subscribers on a threaded server (tests/contrib/flask/test_battle_285_{scenario, a,b}.py,examples/integrations/flask_wizard/tests/ test_readme_commands.py). Found and fixed: a double-click on the SQLite wizard was an HTML 500 (a rawConflictError) and a re-posted earlier step’s form was APPLIED to the current step, advancing the wizard with empty answers – the example serialises writers per key (PessimisticLock), every form names its step and a stale one is refused under the lock viag.xsm.skip_save()(which the documentedg.xsmhandle did not expose). Library errors raised in your OWN views – a lost optimistic race,act()in a GET view, an oversized session – are RFC 9457 problems (409 / 405 / 413) instead of 500s:init_appregisters the handlers (error_handlers=Falseopts out) andSnapshotTooLargeErrormaps to 413 (changed: the blueprint’s answer for a too-large snapshot was 500); a handler your app registered for one of these classes – before or afterinit_app– is left in place.SessionStore’s cap now covers every machine in one session together (two wizards made a 4.3 KB cookie browsers silently drop; the wizard reset). The Quart shim:/streamchecksOrigin;QuartXState.init_appacceptsallowed_origins,max_connections_per_key,clock,migrator,error_handlers;act()passes the request principal to the inbox. Docs: the guide’s Guarantees box claimed losers “retry” – nothing retried; it now documentsPessimisticLock()andpersisted_retrywith runnable blocks, the exact CSRF recipe for the JSON blueprint (X-CSRFToken+ the token’s session cookie),g.xsm.skip_save, the shared cookie cap, aflask xsmreference in the CLI guide with a tested transcript, everycontrib.flask/contrib.quartname in the API index; the example gainedWIZARD_STORE=sqlalchemy, its README commands run literally from a fresh copy, and thexsm newflask scaffold stays file-for-file identical to it. - SQLAlchemy integration, as battle-tested (#284). An orders service
on SQLAlchemy 2.0 as a team runs it – a 16x25 writer fleet on one row
(gapless audit
seq), 200 orders with two concurrentDueTimerScanners (then 2000 rows / 4 scanners, one crashing: every key woken once), the outbox transactional WITH the mixin row, a relay crashing mid-batch that re-sends the same envelope id,forget()cascades, Alembicupgrade headfrom empty / demo / autogenerate no-diff / a team’s next migration /downgrade base– on SQLite always and on Postgres 16 (a testcontainer; CI’s Linux[sqlalchemy]cell runs it) (tests/contrib/sqlalchemy/test_battle_284_{scenario,a,b}.py,examples/integrations/sqlalchemy_orders/tests/test_outbox_and_readme.py). Found and fixed:StatechartMixin.send(plugins=[OutboxPlugin( SQLAlchemyOutboxStore)])– andIdempotencyPlugin(SQLAlchemyInbox)/AuditPlugin(SQLAlchemyLog)– wrote their rows on a connection of their OWN, so an outbox row (or an inbox mark that then answered the redelivery as a duplicate) survived the caller’s rollback; the sinks’ store is now bound to the row’s session for the send (SQLAlchemyStore.bound_to), a send inside an openstore.transaction()is refused (RuntimeError) and a plugin store on another database is refused (ValueError). Postgres/MySQL lock timeouts, deadlocks and serialization failures (SQLSTATE 55P03 / 40P01 / 40001), including on theFOR UPDATErefresh oflock="pessimistic", areLockTimeoutErrorand retried bysend_with_retry(they escaped as rawOperationalError).statechart_stateisText(wasString(512): Postgres raisedStringDataRightTruncationon a 12-region parallel chart while SQLite stored it silently; existing schemas need anALTER COLUMN ... TYPE TEXTmigration). Alembic autogenerate reported a permanentstatecharttype diff on Postgres (create_allemits JSONB, the documentedrender_itemwrotesa.JSON()):StatechartType.compare_against_backend+ the newrender_statechart_typehook render the JSONB variant.xsm_outboxis part ofxsm_sqlalchemy_ddl()(autogenerate wanted to DROP it with its pending events).StatechartTyperaisesSnapshotTooLargeErrorunwrapped (not SQLAlchemy’sStatementError) and a corrupt column isSnapshotCorruptError(not a bareJSONDecodeError).SQLAlchemyStore(create_tables=False)on an empty database says “run your migrations” instead of “no such table: xsm_schema”. The example publishesorder.paidthrough the outbox (sync_app.py relay, migration0002), its README commands run literally on both dialects, and the guide documents the outbox (at-least-once), the Alembic hook, the async store’s lack of a shared transaction, the refusals and per-connectionlock_timeout. - Adoption kit, as battle-tested (#309). The first fifteen minutes
as a newcomer has them – the built wheel installed into a FRESH venv
(3.9 and current), the journey page’s blocks run in order against the
installed package,
xsm newscaffolds tested, the GitHub Action’s and pre-commit hooks’ exact command lines executed (incl. drift and a broken chart), the decision tree’s leaves resolved, the schema and PyPI metadata checked (tests/test_battle_309_adoption.py,tests/test_battle_309_docs_truth.py; CI’s[fastapi]cell runs the venv test). Found and fixed: the journey’s install line omittedfakeredis[lua], so step 5 died with a rawunknown command 'evalsha'– the page says so andRedisStorenow names the cure;xsm new --template flaskships (theflask_wizardexample, file-for-file) and--template djangopoints at thedjango_approvalsexample instead of a closed issue; theNext: cd ...hint quotes a path with spaces;action.yml,.pre-commit-hooks.yamland the CLI guide pinned av0.14.0that does not exist (now the shipped version, pinned by test); PyPI metadata gained Documentation / Changelog / Source links and the README’s relative links are absolute; eleven stale docs claims corrected (the coverage gate,meta.tools,meta.publishand the SQLAlchemy outbox are shipped;**[0.12.0]**labels are 0.11.0; residents are ASGI-only; the Celery leaf of the tree continues; the operational limits box names real limits) and no guide page is an orphan. Touchingpyproject.toml(the project URLs) also ran the weekly compat matrix on this PR, which was red on its oldest cells: the FastAPI OpenAPI golden now normalises what FastAPI 0.106 / pydantic 2.5 render differently, and the[testing]plugin tests survive pytest-asyncio 0.23 and hypothesis 6.100. - Codegen companions, as battle-tested (#279).
xsm gt --with-api --with-modelsacross the whole 104-chart corpus, in-process: every chart that generates yields a router and models that parse, import, mount and document one route per event; regeneration is byte-stable;--check/--diffdetect drift; odd event names slug into unique routes andoperationIds; output dirs with spaces / unicode /#/'; bounded time. Found and fixed: the generated router was weaker than the library’sStatechartRouterin six ways – a 2 MB body was 200 (not 413),text/plainwas 422 (not 415),{"wait": false}was a 500,{"priority": true}was accepted and jumped the queue, the 422 error list was uncapped, and an idempotent replay was SAVED every time (three retries took the version from 1 to 3, so the original writer lost with 409); a per-eventauthorizedenial was answered as a 403 problem from inside the handler so the enclosingget_interpretersaved and the version bumped for a refused request – the generated handlers now callregistry.send_eventunderbounded_route_class, exactly the library’s path (a parity table of 11 cases is pinned; regenerating an older router replaces itsget_interpreterhandlers –--diffshows it); a payload field namedwait,priorityortypecould never be sent (wait/priorityare send options the registry refuses;typeis the discriminator and silently took the event’s name) – refused at generation with one line; a bare JSON-Schema{"type": "object"}payload was read as a field calledtype; the router’sRegenerate with::command omitted--with-modelsand did not quote paths with spaces, so following it did not reproduce the file; the header told users to call abuild_<name>_machine()that no template generates; a generation refusal reached only the logger;--check/--diffstopped at a stale logic file and never reported stale companions;--check/--diffCREATED the output directory and imported + mounted every web companion (a FastAPI build per chart – it compares text now and writes nothing, pinned by an mtime/bytes comparison and by a test that makes the web verification raise); a payload schema whose root type is a list ({"type": ["object", "null"]}) was read as a field map with a field namedtypeand refused (a JSON-Schema root is recognised by its type keyword; a non-object root is refused for the right reason);-o FILEcrashed with a traceback (exit 2, one line); an unrequested leftover companion (--with-apidropped) was invisible to--check(named in a warning); two charts with the sameidsilently overwrote each other’s output (a warning names the other source);gtwith several JSON paths hung on the “Is this correct?” prompt in a non-interactive run (EOF accepts the detected parent;-jp/-jcis the prompt-free path). Confirmed: odd context keys (order-id,class,1st,model_config) become aliased fields that round-trip; chart ids that clash with stdlib modules get a_machinesuffix; hierarchical runs emit the parent’s companions only; provenance banners carry basenames, never a local path. - Litestar integration, as battle-tested (#278). The orders chart
served through
create_statechart_controller+XStatePlugin, driven the way the #275 / #276 battles drove the FastAPI surface, so every hardening of the shared registry is pinned here too (honest idempotency, gates onauthorize, body rules that never echo, 50 concurrentPAYs charge once, streams over a raw ASGI driver). Found and fixed:contrib.litestarre-exported Starlette’sReceiptResponse, which a Litestar handler cannot return – the documentedProviderecipe answered 500 (a Litestar-nativeReceiptResponsenow);get_interpreteranswered 500 whenever it sat next to another dependency – Litestar resolves and cleans up generator dependencies in separate tasks, soact()was entered in one task and exited in another and the commit-scopeContextVarreset raised “Token was created in a different Context” (the wholeact()now runs in one owning task; the save happens when the handler finishes, the change is discarded when it raises); twoProvides for one key opened twoact()s (one interpreter per key per request, as in FastAPI);Idempotency-Keywas ignored onProvideroutes (400 / 501 / stamped on the first send, as in FastAPI);to_starlettelost a body Litestar had already read –send_eventwithout a payload hung on the spent stream (the cached body is copied);_json_guardread a chunked body with nocontent-lengthfully into memory before 413 (streams and stops past the limit); withcreate_if_missing=False/streamopened an endless SSE and the WebSocket accepted a missing key – 404 / 1008 now, and the same gap fixed in the FastAPI router; registeringXStatePlugintwice ran the registry lifespan twice;dependencies=Truesilently replaced a user dependency of the same name (ValueError); Litestar refuses two handlers on one path, so an app could not own a per-event route (the payment hook) – newexclude_events=/guards=/dependencies=on the controller, with/sendrefusing excluded events; the served OpenAPI document changed on every start (random msgspec examples inProblem) and listed only 200 / 400 for every route; colliding event names (ORDER.PAID/ORDER_PAID/get) made/schema/openapi.jsonanswer 500 and one handler overwrote the other – the FastAPI battle’s stableoperationIdrule now lives incontrib/_openapi.pyand both routers use it; aliased event fields were 422; every 200 wasschema: {}(typedStateBody/ReceiptBody/EventsBody); two machines nameda_b/aBshared one body schema; 422 problems named the whole body instead of the unknown key and were uncapped (50 +errors_total). Parity table: the same requests through the FastAPI router and the Litestar controller over one registry give identical status codes and problem titles.TestClient.streambuffers SSE (documented: use a raw ASGI driver orhttpx.ASGITransport). - Multi-worker deployment, as battle-tested (#277). The guide’s and
the
fastapi_ordersREADME’s claims, run against REALuvicorn --workers 4processes plus a separate--role schedulerprocess sharing only the store: 200 concurrentPAYs charge exactly once with or without a sharedIdempotency-Key(the fake gateway logs every call across processes: one line); 100ADD_ITEMs from 4 workers converge after client 409 retries and the stored version equals the successful sends; web workers never fire timers – the scheduler process fires the payment timeout exactly once, and two schedulers started by accident still fire each deadline once; a rolling restart leaves the store consistent; the README’s load test reruns green. Found and fixed: the Docker image could not start (app.pyimportsmigrations; the Dockerfile copied neithermigrations.pynormachine_v2.json), its healthcheck probed/_xsm/healthinstead of/_xsm/ready, and nothing pinned the scheduler to one replica; the guide’s “54409s” and the README’s “Redis” load-test rows were one run’s number and SQLite numbers respectively (loadtest.pydroppedXSM_REDIS_URLsilently) – replaced by measured ranges over 3–5 runs per worker count, a--redis URLflag,--jsonoutput with theconflict_retriesthe issue asked for; a request whose client sent headers and then stalled held a handler forever – newStatechartRegistry(body_timeout_s=30)→408RequestTimeoutError(the example setsXSM_BODY_TIMEOUT_S); N workers racing an empty SQLite file to switch it to WAL: the loser’sdatabase is lockedwas instant (SQLite’s busy handler does not wait for that lock) and the worker silently kept rollback-journal mode for its whole life –SQLiteStoreretries forbusy_timeoutand re-reads the mode another process set; the issue’s promised/metricsendpoint was missing (added;PROMETHEUS_MULTIPROC_DIRcombines workers); the[testing]“TestClient fixtures” and the cookie-keyed wizard block in the guide were a promise and a fragment (corrected, executable). Diagnosed, not ours: on Windows,uvicorn --workers Nsometimes freezes a worker insideaccept()on the shared listening socket until the next connection arrives – one request in a burst stalls for the whole client timeout while p95 is ~0.3 s; reproduced with a do-nothing ASGI app (7 of 25 fleets, never with one process), so no server-side timeout can bound it; documented in the Troubleshooting table with the recommendation (Linux / Docker for multi-worker; N single-worker processes behind a proxy on Windows), and the process-spawning fleet tests skip their stall-sensitive assertions on win32 and run on the Linux / macOS cells. - FastAPI integration, as battle-tested (#276). The order service’s
FastAPI surface under misuse. Pinned on
fastapi_orders: anIdempotency-Keydedups across retries with an inbox (replayduplicate, a different body 422 with no echoed value); a handler that raises after a send persists nothing and leaks no exception text; aBackgroundTasksclosure holding the request’s interpreter finds it stopped (open a freshact());PAYis gated on the registry’sauthorize, so/sendis 403 and a second router on another prefix is no backdoor to the email hook; body rules (422 / 413 / 415) never echo the value; the OpenAPI document generates in bounded time with uniqueoperationIds and no response schema promisingcard_token; 50 concurrentPAYs under one key charge once. Defects found and fixed: anIdempotency-Keyon a registry without an inbox was silently ignored – 200,duplicate: false, three retries ran the event three times (IdempotencyNotConfiguredError→ 501 onsend_eventandget_interpreter, which validates the header and hands it toact(idempotency_key=)so the handler’s first send is deduped with no handler code – also onrequest.state.xsm_idempotency_key); event-routeoperationIds are stable when an event is added later (plain names claim the unsuffixed id; folded names such asORDER.PAIDtake the suffix); twoget_interpreterparameters on the same key in one route gave a self-inflicted 409 (one interpreter per (machine, key) per request);instrument_appcalled twice duplicated the probe routes and nested the lifespan (idempotent); aper_event_dependenciesentry for an event the machine does not declare was silently dropped – a typo left an event ungated (ValueErrorat router build); a 10 000-item validation failure produced a 499 KB 422 (at most 50 errors +errors_total); duplicateoperationIds made the document invalid (ORDER.PAID/ORDER_PAID, an event namedGET/send; non-ASCII ids) – ASCII-folded with deterministic suffixes; anEventModelfield with analiaswas 422 on a valid body on/send,/events/Xandsend(Model(...))(by_alias=True– actions now read aliased fields under the ALIAS key inevent.payload;contrib.pydanticis unreleased, so no published user is affected); fallback body schema names collided for machines nameda_b/aB; 400 / 401 / 501 / 503 problem responses were undocumented. Added: an OpenAPI golden (tests/contrib/fastapi/openapi_golden.json, regenerate withXSM_UPDATE_GOLDEN=1). Confirmed: the save commits BEFORE the response is sent (no acknowledged-but-lost write);GET /{id}/eventsevaluates guards against the live instance – guards must be pure (documented). Deferred from the issue, documented:GET /{id}/history(needs a log store on the registry) and the/schema/*routes (covered by/{id}/diagram.mmd,/{id}/eventsand/openapi.json). - Starlette integration, as battle-tested (#275). The order service’s
StatechartRegistryunder real web load. Pinned onfastapi_orders: 50 concurrentADD_ITEMPOSTs to one order on SQLite lose nothing (409s retry to convergence; stored version == successful sends); 4EventSourcereaders plus a stalled subscriber – the stalled one is cut atMAX_BACKLOG, the writers never wait; streams are isolated per order; 5 000 orders leave no per-topic state; a resident handle kept after LRU eviction is refused, not a silent sink;lifespanexit with a stream open and a POST in flight never hangs pastdrain_timeout_s. Defects found and fixed: the fan-out’s per-topicseqlived forever (freed with the last subscriber); a stream opened during shutdown was 429 (now 503);peek()ran the initial state’s entry actions on every GET / stream connect (a pure probe now); a no-op send (an undeclared event, an event to a finished instance) still saved and bumped the version, so a concurrent real writer lost with a 409 (skipped only when no action ran either – a targetless action-only transition is saved so its outbox / audit marks flush – and unless anIdempotency-Keymust be recorded or the blob needs the currentmachine_version); an empty instance key was accepted (400); a malformedIdempotency-Key(> 255 chars / non-ASCII) gave a silent 200 with the event never run (400);receipt_to_statusaccepted600/"200"(ValueError);release_residentswallowed a fenced save – the resident’s work was lost with a log line (raisesConflictError; background eviction still only logs);resident()while draining was a bareRuntimeError→ 500 (newShuttingDownError→ 503); residents abandoned on drain timeout kept running (stopped);run_timers=Truewith an async store failed insidelifespan(TypeErrorat construction);heartbeat_s0 / negative / nan made the SSE loop busy-spin (refused); dead SSE clients held their connection slot under ASGI spec ≥ 2.4 (Starlette raisesClientDisconnectwithout closing the generator) – released exactly once, and a disconnect is noticed immediately, not at the next 15 s heartbeat; timer (after) transitions fired by the registry’s scanner never reached open streams (the scanner saves throughpersisted()in its thread) – aTimerPublisheron the scanner’s plugin list publishes committed receipts onto the serving loop; malformed WebSocket frames closed the session with 1003 and a non-UTF-8 binary frame crashed it (an error frame now; oversized frames close 1009); WebSocket close codes: 1001 on shutdown (was 1013), 1013 when cut for lagging (was 1000), 1011 on a snapshot-load or encode failure; a peer that vanished duringaccept()kept its slot; an unserialisable frame killed the SSE stream with a traceback (logged, stream ends, slot freed). The SSE / WebSocket wire contract (frames, reconnect = fresh snapshot with no replay, close codes) and the per-process fan-out rule are documented;registry.pysplit (_act_helpers,_probes,_scanner). - Live inspector, as battle-tested (#274). The fulfilment team
watches the pipeline in the Stately Inspector and records sessions for
post-mortems. Pinned on
eda_fulfilment: 20 orders are 20 distinct sessions each ending in its ownshippedsnapshot; only allow-listed context leaves and the chartdefinition’s initial context is filtered the same way; a browser tab that never reads is bounded, cut and counted while a reading tab keeps receiving; a sink that raises never reaches the machine; a recording survives a crash mid-write; 3 readers over 300 orders see every frame in the same order; 8 router threads over one sink. Defects found and fixed: the session id wasinterp.id– the chart id – so every persisted instance of one chart shared a session and the Inspector drew one actor namedorderwith 20 orders’ snapshots interleaved –session_id_of()uses the persistedstore_key(children re-rooted under the parent’s session);SseSinkkept an unbounded per-client queue (≈11 MB per 20 000 events for a stalled tab) –max_queue=10_000, a lagging client is cut (reconnect replayshistory),dropped/sent/clientsexposed,send()never blocks; the same for the[starlette]WebSocketSink(max_queue=, close code 1013); the loopbackHostcheck accepted127.0.0.1.evil.com(startswith("127.")) – the DNS-rebinding guard now parseshost[:port]/[v6][:port]strictly withipaddress; a sink that raised produced one contained error per message – reported once, then inert;read_jsonlraised on a recording whose last line was cut by a crash – the truncated last line is skipped (a corrupt line in the middle still raises);include_payloads=Truesent free-text payload fields through key-name redaction – a card number inPAYMENT_FAILED.reasonleft the process –payload_allowlist=(deny by default, likecontext_allowlist;include_payloads=Truewithout it sends onlytypeand warns once);MemorySink(maxlen=); the pending-sends table grew with unique target ids (capped at 1 024);replay_messages(Path(...))raisedTypeError(os.PathLikeaccepted); a negative / NaNspeedwas accepted (ValueError; the CLI exits 2);xsm sim --recordappended to an existing file – two unrelated sessions replayed as one; it now refuses a non-empty file unless--append(behaviour change);xsm replayloaded the whole recording before printing (streams);--openon a headless box crashed the server (warns); Ctrl-C during--livecould not interrupt the wait on Windows (slices);JsonLinesSink.send()afterclose()raised an opaque I/O error (clearValueError);SseSink(keepalive=)is injectable. Documented gaps:historyValueis always{}, a not-started run reportsactive, noSecurecookie flag over http, HEAD/POST/OPTIONS answer 501; astore_keyshared by two different charts shares a session – keys must be unique across charts. - Observability, as battle-tested (#273). The fulfilment pipeline on a
dashboard an SRE can trust: Prometheus + OpenTelemetry on every
interpreter the choreography router builds. Pinned on
eda_fulfilment: every counter equals the audit log after clean orders, a cancel mid-flight and a dead-lettered poison command; 1 000 undeclared event types × 1 000 subjects mint oneunknownseries and the scrape stays under 64 KB with no order id / payload / envelope id as a label or span attribute; a span exporter that is down never reaches the machine and spans flow again when it recovers; service spans are children of the transition span that entered the invoking state; 8 router threads over one registry / one tracer sum exactly; 700 orders leave the plugins’ bookkeeping empty. Defects found and fixed:xstatemachine_active_interpretersonly decremented inon_interpreter_stop, so machines that finished (done), failed (error) or were garbage-collected pinned it forever (30 shipped orders → gauge 30) – it is now polled at scrape time (count ofstatus == "running", likequeue_depth; the two polled gauges are per-process and do not appear in prometheus_client multiprocess mode); a cancelled invocation (state exited while the service ran) fired no hook, so its OTel span never ended and a re-entered state restarting the same invokeidlost the earlier span until process exit – spans of services whose owning state left the configuration are ended after each event (and onon_done/on_error) withstatechart.cancelled=true; a tracer / Sentry SDK that raised produced one contained error per event – both plugins now go inert after one warning; twoPrometheusPlugins with different base label sets on one registry hitDuplicateTimeseries–ValueErrornaming the conflict;record_context=Truewrote therepr()/ bytes of non-JSON context values into the span (aSession(password=...)repr leaked pastredact(), which matches keys) – opaque values are written as type tags; a context dict with mixed key types made the sorted dump raise after the span left the stack, so the event’s span was never exported – ended infinally;StructlogPlugin/LoguruPluginkept the in-flight stack in athreading.local, which every asyncio task on one loop shares – two interpreters interleaving corrupted and leaked each other’s fields; the stack is a per-taskContextVar;correlation_idhad no size cap (128 chars now);LoguruPlugin(logger=<stdlib logger>)was accepted and failed on every event (TypeErrorat construction);instrument_all(interp)on a running interpreter missedon_interpreter_start(replayed). Engine: a plugin instance registered globally and attached with.use()(or.use()-d twice) fired every hook twice – the limitation documented in #305 – and every metric doubled;use()now dedupes by identity on both engines. Overhead, measured once on a two-state chart (Windows, CPython 3.14; not a budgeted benchmark): roughly 3× withPrometheusPlugin, 4–5× withOpenTelemetryPlugin(SDK, no exporter), about 40 % of it the engine’s per-event receipt. - Test doubles, as battle-tested (#272). The fulfilment team runs its
two-chart EDA pipeline with no broker. The #272 acceptance criteria
promised
given(machine).in_state(...).when(...).then_state(...)and it never shipped:contrib.testing.given()/Scenarioexist now (sync engine,SimulatedClock,from_state_idsfor the starting configuration;in_state/with_context/when/after/then_state/then_not_state/then_context/then_changed/then_denied/then_error/then_no_error/then_done; every failure names the step trail, the expectation and what was active). Pinned oneda_fulfilmentwithSyncFakeBrokerAdapter: an injected publish failure leaves the outbox row pending and the relay raises to its caller – the next tick publishes it once (at-least-once, no duplicate); a four-publish partition delays but never reorders a subject’sOrderPaid → OrderPacked → OrderShipped; a raising handler nacks without requeue and the exception reaches the test; a poison command is attempted exactlyMAX_ATTEMPTStimes, then dead-lettered and acked with nothing committed or pending; 50 interleaved orders keep per-subject order and every published event’scausationidnames its inbound cause; 8 producer threads lose nothing and the fake’s counters balance;assert_replay_consistentholds for every order and a tampered log raisesReplayDivergenceError; 10 000 envelopes through the fake stay ordered per subject with linear memory. The adversary suites then found: the fake redelivered anack(requeue=True)withattemptstill 0 while every real adapter adds 1 – poison tests passed on the fake for a different reason than on a real broker;subscribe(timeout=)was a TOTAL limit in the fake and an IDLE limit in the adapters, so a trickling producer ended the iterator mid-backlog; the fake handed out the SAMEEnvelopeobject it stored – a consumer mutatingdatarewrote thepublishedrecord and a nack redelivered the mutated payload (copy-on-wire throughto_json(max_bytes)/from_jsonnow, which also enforces the X0.4 size cap onpublishanddeliver);fail_next_publishacceptedKeyboardInterrupt, an exception CLASS andtimes=0; a handler that republished to its own topic madedrain()loop forever (limit=DEFAULT_DRAIN_LIMIT);assert_replay_consistentread the store with the defaultlimit=1000– a log tampered at record 1 200 was “consistent” (every page is read now);upto=-1silently replayed nothing (ValueError;uptois inclusive,0is the initial state). The contract suite existed only for async;SyncBrokerContractnow runs against the sync fake and the realSyncBrokerbase. New:clear()on both fakes,ReplayDivergenceErrorexported fromcontrib.testing,eda.DEFAULT_DRAIN_LIMIT. Ingiven(): a history id gave a misleadingInvalidConfigError,in_state()with no ids passed through, a top-level final start stayedrunning,after(nan)was accepted,when()afterstop()was a silent no-op, and the step trail omitted the given steps. pytest-bdd recipe (tests/contrib/testing/bdd_order_specs/, skipped withoutpytest-bdd); benchmark rowsfake_broker_10k_envelopesandgiven_when_then_spec(no wall-clock budget; runbenchmarks/to measure on your hardware). - Model-based testing, as battle-tested (#271). The orders team’s
path tests (#269) and coverage gate (#270) were green and a refund still
drove
total_centsnegative after a specific interleaving.model_teston the orders chart with the reallogic.build_logicfinds the planted bug, shrinks it to ≤ 6 steps, writes afailing.jsonthatxsm simulate --scriptreplays, and writes the same bytes twice under a fixed seed. The bug it found in itself: a logic factory was called once per class, so real logic with state (a gateway stub counting calls, a retry counter, a breaker) was shared across examples – Hypothesis reportedFlakyStrategyDefinitionand the planted bug reproduced only on the first run. The factory is now called once per example (None/ a bareMachineLogicinstance keep one build); theMachineLogiccheck is duck-typed so an example app importing the installed package pairs with asrc.test. Pinned: 500 examples withallow_denied=Falsegenerate no engine-refused send; a payload- dependent guard is gated on the generated payload;state_assertionsrun per active parallel region;snapshot_roundtripnames asetin context;clock=Truereaches the 15-minuteexpiredstate (coverage sees theafter); 200 examples onaddressFieldsand the orders chart finish within budget; a 12-chart corpus smoke raises no false failure (analways-looping export is the engine’sRunawayChainError, not the model’s). The adversary suites then found: two events whose names differ only in punctuation (A.B/A_B) became ONE rule name – one event was silently never generated; araise/sendTowithdelayor a child’s timer never enabled the clock rule (it looked only at activeafterkeys), and with noafterat all the rule did not exist – it now advances to the next pending timer of any kind; astate_assertionskey naming no state was silently ignored (ValueError); an assert-style check returningNonefailed as “violated” while a check raisingKeyErrorescaped with no script written –Nonepasses, any other falsy value fails, any exception fails through the artefact writer; payload inference raised “cannot infer” forDecimal/datetime/date/ nested models and ignored field constraints (Field(ge=1)→ every send refused); apayloads=strategy producing non-dicts or atypekey failed obscurely (TypeError).xsm simulate --script: a malformed script (a step withoutsend/clock/guard, a list payload, a negative / infinite / non-numericclock, a missing file) printed a traceback, a non-boolean guardvalue("false") silently INVERTED the flip, and--scriptwith--eventsran both so the replayed artefact was not the recorded run – one-line errors, exit 2. Pinned: the file left after shrinking is the minimal sequence; three planted bugs (sequence-, payload-, time-dependent) shrink to minimal sequences (≤ 5 / 1 / 2 steps; exact lengths vary by Hypothesis version); 200 examples on the orders chart with real logic take a few seconds; aDecimalin context failssnapshot_roundtripon purpose, with a hint. Independent review then found:assume()inside an invariant raisedUnsatisfiedAssumption– anException– and was reported as a failure with a script written and a non-bug shrunk; Hypothesis control exceptions now pass through. Field bounds (Field(ge=1000, le=1001),min_length) are read into the integer / text strategies instead of filtering a default range that could never pass (Unsatisfiable). New benchmark rowmodel_test_200_examples;failing.jsonis gitignored. - Coverage gates, as battle-tested (#270). The orders team gates CI
on state & transition coverage:
--xsm-fail-under-state-coverage=nanwas accepted (type=float) and everypercent < nanis False – the gate silently PASSED at any coverage;inf,-1and101were accepted too. Thresholds are now a finite percentage in[0, 100](argparseerror otherwise). Pinned on the shipped chart: a suite that only pays namespaymentFailedand theawaitingPayment --CANCEL/retryDelayedges and fails the gate;term/json:/html:agree; the JSON isversion: 1and byte-identical across runs; the HTML is one self-contained file;xsm coveragerenders it and--fail-underexits 1; restored, directly-built and rebuilt interpreters all count (one row per structure key); parallel configurations mark leaves and ancestors; history pseudo-states are not in the denominator; nothing is registered without the flag;-n 2matches serial. The adversary suites then found: the collector kept EVERY machine build and its transition index alive for its lifetime – a session that rebuilds a chart per test grew without bound (3 000 builds → 3 000 entries); one representative build per key is kept, later builds are held weakly, and a recycledid()can never land in a stale index. The terminal summary put every unvisited state / unhit edge on one line (500 edges → a 20 kB line); lists are capped atTEXT_LIST_LIMIT(20) with “… and N more” (JSON / HTML keep the full lists).below()accepted NaN (the gate silently off) –ValueErroroutside[0, 100]. Report options / thresholds without--xsm-coveragewere silently ignored (usage error now); a set threshold PASSED when no machine was observed (-kmatched nothing) – it fails; an unwritable report path was an INTERNALERROR traceback;--collect-onlywrote files and gated; a run stopped by-xgave no sign the numbers were partial; a crashed xdist worker is now named in the summary (not awarnings.warnthat-W errorturns into an INTERNALERROR). Honest numbers: the denominator is statically declared – on 30 corpus charts 105 of 731 targets can never be hit (shadowedalwaysalternatives, inline-actor invoke outcomes the stubs cannot drive, namedafterdelays with no implementation, states no event sequence reaches, self-targetonhandlers that generated paths skip), so 100 % transition coverage may be unattainable on a real chart – gate on states, or on a transition threshold below 100; and the session collector costs ≈1.4-1.7× onsend()throughput, about three quarters of it the engine’s generic plugin dispatch (a no-op plugin costs ≈34 %), the collector’s own share ≈10 %. - The pytest plugin, as battle-tested (#268). A team adopting
[testing]for the orders chart (real logic through the dotted factory, snapshot files in git,pytest -n 4):xsm gt -t pytest --fixturesemitted aCONFIG_PATHwith a barewith_name()while the plain variant already fell back to the parent directory – the generated module could never find its chart from the documented-o generated/layout; a misspelt name inxstate_guards_falsewas silently accepted (the test exercised the True branch while claiming to force False) – unknown guard names are a usage error naming the known ones;--xsm-update-snapshotswrote wherever a relative path with enough..pointed – snapshot paths outside the rootdir are refused (X0.10). Pinned: snapshot files are byte-identical across update runs and under-n 4, aDecimal/ tz-awaredatetimecontext renders deterministically,"+nan"/"+-5"/"+inf"inxsm_send_allare errors not hangs,-p no:xstate_statemachineleaves a marker-less module untouched. The adversary suites then found:--xsm-coverageunder pytest-xdist always PASSED – workers collected, the controller printed “(no machines observed)” and gated nothing; workers now ship reports to the controller, which merges, prints, writes and gates once (same JSON as a serial run).model_testfailed falsely on a chart whose root reaches a final state (event rules stayed enabled afterdone→InterpreterStoppedError) and on analwaysthat returns to the same configuration (“generated a denied event” aftercan()accepted it).FakeBrokerAdapter.deliver()accepted a non-Envelopeand failed later inside the consumer (TypeErrorat the call now);SyncFakeBrokerAdapter/BrokerPublishErrorare exported fromcontrib.testing. In the plugin: a logic module raising anything butImportErrorescaped as a traceback (one usage error naming file:line);logic=now also takes aMachineLogicinstance or a zero-arg callable; context sets rendered in hash order so a snapshot recorded under onePYTHONHASHSEEDfailed under another (sorted lists); two tests writing different content to one snapshot path silently last-won (refused); Windows\?\resolved paths were intermittently refused as “outside the project”; snapshot writes are atomic (temp +os.replace, LF).xsm_send_all/xsm_asend_allgain a payload form (Event,{"type": ...}dict,("TYPE", {...})tuple) and refuse"","A,B"and"++5"(the empty string sent nothing, the comma sent two events,++5advanced 5 ms).xsm gt -t pyteston a chart the engine rejects exits 1 with one line instead of a traceback.pytest_plugin.pysplit into_marker.py/_snapshots.py(public import path unchanged). Perf rowpytest_plugin_per_test(p50 ≈ 1.1 ms); testing guide gains a Fake broker section, coverage-under-xdist,model_testinvariants/deadline notes,--xsm-failing-dir, threat-model notes (a marker’slogic=is code; diffs print context unredacted) and six Troubleshooting rows. Independent review then found: the same-path-different-content snapshot refusal lived in a per-process stash, so under-n 4two WORKERS writing one file still last-won silently – a file written this session that already differs on disk is now the same refusal; a crashed xdist worker’s coverage vanished without a word (oneRuntimeWarningnames the worker); the marker help and the guide still saidlogic=must be a dotted string; the guide now lists what a snapshot deliberately does NOT record (history, actors, pending / scheduled events, deadlines). - Path generation, as battle-tested (#269). The explorer replayed
the WHOLE prefix for every candidate edge – O(depth) engine runs per
edge: the 35-state parallel
addressFieldschart (3 456 configurations) took 60 s and a 53-state / 78-guard chart underguards="both"137 s. Prefix end-states are now cached as snapshots and restored per candidate (restart_timers="resume"on a clock that shares the original’s wall origin – the default leavesafterdeadlines dormant; restored under the last step’s forced assumptions –start()re-runsalways, so a configuration stable only while a guard is forced False moved on under the all-True stubs), and guard flips are scoped to the candidate’s own event (a guard onon 'X'cannot changesend('Y')). Results are byte-identical on 100 corpus charts in every mode; the two charts run in 34 s and 39 s. A forcedservice:<name>=errorstep no longer logs an ERROR traceback per generated test underxsm_path. The adversary suites then found: the cache stored a snapshot per EDGE (19 000 onaddressFields), overflowed its cap and fell back to replay – 77 % hit rate; BFS now caches the first path into each configuration (≈100 %, 35 s → 19 s). Wildcard handlers ("*","mouse.*") were skipped, so a state reachable only through one was “unreachable”. No bound on configurations:max_configs=100_000(ExplorationLimitError, partial result on.found); negativemax_depth/max_paths/max_configsareValueError.stubbed()swappedmachine.logicon the CALLER’s machine – a live interpreter on it took a transition its real guard forbids while a traversal ran; the explorer now works on a private copy, and the logger-level guard is re-entrant (two overlapping traversals could leave the library logger muted).xsm_path: a chart that cannot start aborted the whole collection with a traceback (now onepath[error]case per test); two functions on one chart explored it twice (cached per session); negative--xsm-max-*are usage errors.xsm paths: a chart that cannot be explored exits 1 with one line;--weight steps|timeadded; negative bounds exit 2; every printed path’sevent_string()fed toxsm simulate --eventslands on the same states (102 corpus charts).xsm simulate --events/--clock:+-5,+abccrashed,+inf/+nan/+1e309printed invalid JSON – all exit 2.xsm inspect/validatecalled entered states “unreachable”: the static pass marked only a transition’s target, not the ancestors (and parallel siblings) a deep#id/ history target enters – 16 false warnings removed, none added. Docs: an UNDEFINED named delay is skipped (only one inlogic.delaysyieldsdelay:<name>=unknown). Independent review then found: the cache-parity tests patched a re-exported copy of the cache cap, not the one the explorer reads – every “cache ≡ replay” assertion compared the cache with itself (the parity claim above was re-established after the fix); a namedafterdelay advanced the clock a fixed 10⁹ ms and fired every later timer in the same step, soa --after slow--> b --after 300--> creported only{a, c}– it now advances to the earliest pending deadline; a step the engine refused or contained (maxIterations) vanished silently – oneRuntimeWarningper traversal names it;reachable_statesgainsmax_configs;graph.pysplit intograph/_graph_model/_graph_explorer. - Typed boundary, as battle-tested (#266). A chart whose static
contextthecontext_modelrefuses built and started – theTypedContextPluginraise is contained by the plugin system – and then rolled back every mutating action forever.create_machinenow refuses it (InvalidConfigError, field path, never the value) when the validator is a model validator (a plain callable keeps the #305 “after mutations only” contract); a bad restored context puts the machine instatus == "error"with theContextValidationErrorasinterpreter.error.{"type": 1}actions and list / non-string transition targets escaped the parser as a bareAttributeError; both the static gate (ActionObject,TransitionConfig.target: str) and the parser reportInvalidConfigErrorwith a path (multi-target lists are not implemented and now say so).contrib.fastapi.instrument_appmaps everyRequestValidationError– including routes the app adds beside the generated router – to the 422 problem shape (field path + error type, never the offendinginput; X0.7). The adversary suites then found:context_model(write_back=True)put nested model INSTANCES into the dict, soget_snapshot()stored"sku='a' qty=2"– an unrestorable snapshot; write-back now usesmodel_dump(mode="python")(plain dicts,Decimal/datetimekept). A rejected value – a card token failing validation – rode in pydantic’s error text (input_value=) intologger.exceptionandInvalidEventPayloadError’s message;ContextValidationErrorand the payload error are now rebuilt value-free (path, type, message; X0.5).persisted()/apersisted()/persisted_retryran the block and then saved a terminalstatus: "error"snapshot over the record whenTypedContextPluginhad just failed a restored machine; they now raise theContextValidationErrorbefore the block and write nothing.PydanticCodecstored a context the model refuses silently; it still stores it (refusing would lose state) but emits aRuntimeWarningnaming the field paths.create_machine: a non-numericmaxIterations/spawnBlockingTimeout, a non-objectstateson an initial-less compound, and a chart nested ~600 deep areInvalidConfigError(were bareValueError/TypeError/AttributeError/RecursionError).validate_machine_jsonrefuses what the engine refuses – a non-objectcontext, duplicate custom stateids, stringstrict*flags (the parser read"false"as true) – takesbytesand a BOM, reports malformed JSON and duplicate JSON keys asInvalidConfigError, prints list paths asPAY[0].target, and names its ~95-level nesting cap; gate ⇔ parser parity holds in BOTH directions on all 168 shipped charts and a seeded 600-mutation fuzz.machine_json_schema: same-named nested models in the event and context schemas no longer overwrite each other’s$defs;x-leaf-statesomits history pseudo-states; a union member without aLiteraltypeis aTypeError.[fastapi]gainsbounded_route_class(registry)– the router’s 413 / 415 / value-free 422 for routes added besideStatechartRouter(the orders example’sPAYroute parsed a 1 MB body and answered 422).event_type_of,ActionObject/ActionSpecare exported fromcontrib.pydantic. Independent review then found: the scrubbed error still carried the value throughctx["error"](aValueErrorfrom afield_validator) and aPydanticCustomError’s interpolated message;ctxis now allow-listed to rule keys (max_length,expected, …) and free-text error types get a fixed message. Write-back raisedKeyErroron a field withexclude=True. And a machine failed by a start hook still entered its initial states – entry actions, services and timers ran on the refused context (the async run loop stayed alive); both engines now return fromstart()without entering anything.instrument_appno longer replaces aRequestValidationErrorhandler the app registered first. FileStore.lock()is fair within a process (battle-test #306, CI). Sixteen threads spinning on the OS file lock with sleeps was a lottery: one waiter could lose every draw for the wholetimeout(4 of 16 hitLockTimeoutErrorat 30 s on the Windows runner). Same-process waiters now queue on a per-keythreading.Lockfirst; the OS lock arbitrates only across processes.LockTimeoutErrorstill names the holder.RedisStore/AsyncRedisStore(battle-test #306, the store). A key containing|woke the wrong instance fromdue_keys(the deadline-index member waskey|field, split on the first|); members are now JSON arrays and the namespace schema marker is 2 (a namespace written by a pre-release build is upgraded in place and its members still read; the[redis]extra has not shipped, so no released version is affected). Snapshots expired byttl_sleft their index members behind and,limitof them, starved every live due key – they are now pruned atomically. A Redis outage while taking a lock escaped as a rawredis.ConnectionError; it is nowStoreUnavailableError. A damaged record or schema marker raised a bareKeyError/ValueError; nowSnapshotCorruptError/StoreError.lock_ttl_ms=0and sub-millisecond / negativettl_sare refused at construction. URL-built clients get 5 s socket timeouts (DEFAULT_SOCKET_TIMEOUT_S), overridable in the URL.AsyncRedisStorehad drifted from the sync store: it now checks the schema marker, refuses a non-strsnapshot / negativeexpected_version/ negativelimit, wraps codec failures, hides planted invalid keys fromlist_keys, validateslock()arguments, and gainsdue_keys.health()never raises.RedisInbox/RedisLog(battle-test #306). Found by a two-host fleet attack before the extra shipped: inbox expiry used each worker’stime.time()(a slow host wrote an already-expired claim and a peer re-admitted the key – a second charge);mark(ttl_s=None)after a TTL’d claim left the claim’s expiry in the index, sopurge_expireddeleted a permanent receipt; a scope containing|was never purged;forget(scope)andlen()were not atomic / glob-safe. The log let 32 concurrent appenders mint 640 records with 37 distinctseqs, accepted a duplicateappend, raised a bareJSONDecodeError/AttributeErrorinstead ofLogCorruptError/TypeError, acceptedlimit=-1and a NaN purge cutoff, and scanned the whole stream for every page.-
Flask: one WARNING line per request for a store outage (battle-test #306).
problem_responselogged a full traceback for every503 StoreUnavailableError– thousands per second during a Redis failover; the Starlette registry already logged one line. An unknown500still logs the traceback. - A circuit breaker could close without a probe (battle-test #265). A
call admitted while closed that reported SUCCESS after the circuit
had opened and half-opened closed it; a late FAILURE likewise re-opened
it. Every admission now carries a window token and an outcome from an
earlier window (or from before
reset()) is dropped. Exactlyhalf_open_max_callsprobes are admitted under 32 / 128 / 512 threads and 500 async tasks. RetryPolicy.exponential_msoverflowed withOverflowErrorforfactor=1e6orattempt=10**6; it returns the cap. Decorrelated jitter with a NaN or negative previous delay is guarded.-
DeadLetterStore.putwas O(n) per insert; 100 000 records are now bounded. One plugin shared by many never-stopped interpreters leaked their chains; the in-memory fallback is weakly keyed. PessimisticLock+DueTimerScannertimed out on its own lock (battle-test #264). The scanner takes the strategy’s lock, thenpersisted()inside the wake tried to take it again – on Memory, File, Django and SQLAlchemy stores (whose locks are not re-entrant) every key, every tick, was aLockTimeoutError. The inner block now reuses the held lock.- A scanner could count a phantom wake (battle-test #264). Under
OptimisticLockanother scanner could fire the key after our re-check but beforepersisted()’s own load; we then loaded a record with nothing due, saved a no-opversion + 1and reportedwoken. The save is now fenced on the version the re-check saw:ConflictError->skipped_stale. Exactly-once per deadline is asserted under 8 threads and 4 processes on every store, both locks. restart_timers=Falseleft an orphan deadline (battle-test #264). A parked deadline whose state was then exited stayed inpending_deadlines(), was re-persisted, and every scanner tick on that key raisedStateNotFoundError. Exiting a state drops its parked record too.- Child actors lost their persisted timers (battle-test #264). Child
restores received
restart_servicesonly: a child’safternever re-armed, and a restored child ran on the real clock under a parent on aSimulatedClock. The sync engine never started restored children at all. - Building machines from one config in several threads raised a false
InvalidConfigError(battle-test #264). The #136 “aliased cycle” guard was one module-level set shared across threads; 1 035 of 2 400 parallelcreate_machinecalls tripped it. The guard is per build. -
DjangoModelStore.savemaps “database is locked” toLockTimeoutErrorasDjangoStorealready did; a raising fence inpersisted()now releases the idempotency-inbox claims it was holding. - The web registries’ READ paths ignored the migrator (battle-test
#263).
StatechartRegistry.peek()(GET, SSE connect, WebSocket snapshot) and both Flask / Quartpeeks restored withoutmigrator=, so a v2 deployment that carried the migrator still answered 409 to every read of a v1 instance until something wrote to it. Reads now migrate like writes and stay read-only (the next write re-saves at the new label). Found by thefastapi_ordersrolling upgrade on its first run. apersisted()saved a torn state – or refused to save – on charts with chained services (battle-test #263). Seeawait_settledunder Added: two plaindefinvokes in a row wereSnapshotMidStepError; anasync definvoke armed by the block’s last event was saved with the task in flight and restored dormant (review H1).xsm snapshots --stalewas wrong and O(n · blob) (battle-test #263).--limitcapped the keys scanned, so stale keys past the first 1 000 were silently missing from the drain list; every key’s full snapshot was loaded to read a label the store has as a column. Now: the label index is scanned for every key and the limit caps output; 10 000 keys with 100 stale on SQLite take 0.10 s (was 1 000 loads and wrong). A key containing a newline no longer splits a table row (control characters are escaped;--jsonkeeps them verbatim); a non-machine JSON, an unreadable store or a 200 000-deep[[[[are a one-line error and exit 2, not a traceback;--jsonnever includescontext.-
Typed errors on the versioned restore path (battle-test #263). An
actorsrecord withoutsnapshotwas a bareKeyError; adeadlinesrecord with a NaN / ±infdue_at_wallescaped asValueError/OverflowError; both areSnapshotCorruptError.register(1, "1")stored a self-loop that matched nothing and int labels never matched (can_migrate("o", 1, 2)wasFalse): labels compare as strings.AsyncSQLAlchemyStoreandAsyncRedisStoreskipped the label type checks the sync stores apply. - Audit records could land for steps that never committed (battle-test
#262; a crash-consistency guarantee). Records were appended as each
event settled, BEFORE the snapshot save – so a lost optimistic attempt,
a block that raised, or a writer killed before the save all left audit
rows the snapshot did not have. Records are now buffered per
persisted()block and released at the save: inside the same transaction whenSQLiteLogshares the store underPessimisticLock, viaafter_commitotherwise. Killed-child tests pin that log and snapshot agree (or the snapshot is one step ahead, never behind).apersisted+PessimisticLock+ a sharedSQLiteLogwrote NO records at all (the flush ran on the loop thread while the adapter’s worker held the transaction ->LockTimeoutError): the flush now runs on the adapter’s thread. seqis assigned atomically by the log store (battle-test #262).NoLockwith 8 threads hitUNIQUE constraint failedand the plugin’s error handling swallowed it – audit rows silently went missing (the handover’s listed limitation). NewTransitionLogStore.append_next()mintsMAX(seq)+1and inserts in one statement; gap-free under every lock and across two processes.replay()was not faithful (battle-test #262).raisechains, events an action sent to itself and re-released deferred events were re-sent, so steps ran twice; a top-levelfinalwas never reached; seq gaps, a purged head and mixed instance keys were accepted silently; amachine_versionmismatch was ignored; checks ran per group, not per record; real services ran again underlogic=;aftertimers never fired when called from async code. NewTransitionRecord.origin(external / internal – only external records are re-sent), per-record verification withReplayDivergenceError.field,key=andsnapshot=parameters, services always replayed from the record, timers drained synchronously. An action raising underactionErrorPolicy: "continue"is now recorded as"error", not"transition".- Log stores raised bare exceptions on damaged records (battle-test
#262). A torn last line (writer killed mid-write), a non-record line,
non-UTF-8 bytes, a string
seqsilently coerced, NaNtsaccepted, afrom_statesstring split into characters –json.JSONDecodeError,KeyError,TypeError,UnicodeDecodeError,sqlite3.*escapingread()andreplay(). Every one is nowLogCorruptError(aStoreError) namingpath:line, never a skip: a replay over a hole would succeed to a wrong state. -
purge_older_than(NaN)erased the whole log (battle-test #262) – everyts >= NaNcomparison is false. NaN / -inf / non-numbers / bools now raiseValueErrorand delete nothing.read(limit=-1)dropped the last row on Memory/JSONL and meant “unlimited” on SQLite -> uniformValueError. A failed JSONL purge rewrite stranded a.tmpand never fsynced -> atomic, fsynced, cleaned up.append()rejects a non-record /seq < 1/ non-finitetsat the call site. - The idempotency inbox’s exactly-once claim did not hold under crash and
concurrency (battle-test #261; four of these broke a stated guarantee).
- Mark buffers were ONE list shared by every
persisted()block using the plugin: block A sentkaand crashed before saving while block B exited cleanly in between – B’s flush wrote A’s mark, and every redelivery ofkagot “duplicate” for an effect that was never saved. Buffers and thebuffer_marksswitch are now per session (thread AND asyncio task). - With
MemoryInbox, a kill after the save left the restart with an empty inbox; the redelivery ran the action a second time on a committed save. The snapshot now carries, for each key in its ring, when it was processed and with which fingerprint; withinttl_sthat answers the same payload as a duplicate and a different one as 422, and repairs the inbox. Old snapshots keep the old behaviour. on_before_sendwas fail-open: an un-fingerprintable payload, a hand-edited cached receipt, an inbox that raised, or a SQLite lock timeout (always the case forapersisted+PessimisticLock+ a sharedSQLiteInbox) ADMITTED the event with no claim, so every redelivery ran again. Each now refuses with a typed receipt; a malformed cached receipt becomes a conservative duplicate with a warning.- A TTL purge between claim and mark made SQLite’s mark an
UPDATEthat matched nothing (redelivery ran again) andMemoryInboxwrite a row with an empty fingerprint (bogus 422). The mark re-claims with the real fingerprint. on_interpreter_stopreleased EVERY pending claim on the plugin, other interpreters’ live claims included. Claims are tagged with their interpreter.- A plugin registered AFTER the inbox that refused the event in
on_before_send(a rate limiter, maintenance mode) left the inbox’s claim in flight – 409 for the whole TTL (7 days) for an event the machine never saw (independent review H1). NewPluginBase. on_event_refused(interpreter, event, receipt)fires on the plugins that had already said “proceed”; the inbox releases its claim there. ttl_s=-1,nan,"7"andTruewere accepted silently (negative / NaN made every key expire instantly – dedup off with no error).ValueErrorat construction.
- Mark buffers were ONE list shared by every
persisted()/apersisted()could not restore a compatibly-drifted snapshot (battle-test #260). The docs listedverify_machine_hashandexpected_machine_hashas pass-through tofrom_snapshot, but neither was in the signatures –persisted(..., verify_machine_hash=False)raisedTypeError. Both are now accepted bypersisted,apersisted, everyLockStrategy.run()and forwarded. A different machine id is still always refused (identity, not hash).after_commitdropped later callbacks when one raised (battle-test #260). An outbox publish registered after a raising callback silently never ran. Every callback now runs; the first error is re-raised; the save is already durable.async defcallbacks are awaited insideapersisted()and refused withTypeErrorunderpersisted()– before, they were created and never awaited. A nestedpersisted()on a different key now runs its callbacks at its own exit (its save is its own commit); they used to wait for the outermost block and were dropped if that block later failed – a committed state change with no published event (independent review M2).apersisted()leaked a worker thread per call on a sync store (battle-test #260). Theas_asyncadapter it created was never closed; each cycle left anxsm-storethread alive until GC. Closed on exit, success or error – off the loop, sinceclose()is a blocking executor join that would otherwise stall every other task (review M1). The defaultOptimisticLock.runalso forwarded only three of the five restore arguments, droppingverify_machine_hash/expected_machine_hashit had just accepted (review H1) – fixed.-
lock=validation (battle-test #260). A non-strategy ("none",5) failed mid-block withAttributeError: 'str' object has no attribute 'acquire'; nowValueErrornamingOptimisticLock/PessimisticLock/NoLockbefore anything runs.OptimisticLockrejects a non-intretries(bool included) and a non-RetryPolicybackoffwithTypeError, and a negativeretrieswithValueError(1.5used to be silently truncated to1). - Two stores could report a successful save that was lost (battle-test
#259; both break the “never a lost update” guarantee).
SQLiteStore: a failedCOMMIT(disk full,database is locked) left the connection mid-transaction, so every later save on that thread joined the dead transaction, returned a version, and was never committed.as_async: a task callingsave()while another task held the adapter’s pessimistic lock ran inside the holder’s transaction and was rolled back with it –savereturned 1,loadreturnedNone. Fixes:_commit_or_rollbackwith typed errors; calls from other tasks wait while a lock is held on that adapter. Regression tests inject the failed commit and the interleaving. as_asyncpessimistic locks did not exclude (battle-test #259): five concurrentapersisted(..., PessimisticLock)tasks on Memory/File got fourLockTimeoutErrors and an 8 s stall; on SQLite no exclusion at all. Lock holders now queue on the loop, one per adapter, bounded bytimeout.FileStorelock waits could spin forever (battle-test #259): a live holder slower thanstale_lock_afterskipped the deadline check, so waiters ran at 100 % CPU and ignoredtimeout. Deadline first.- A legal 200-character key crashed
FileStore(battle-test #259): CJK or"A"*200percent-encodes to 600-1 800 characters -> rawOSError. Encoded names over 200 chars now use~<sha256>;list_keysrecovers the key from the record. FileStorereaders blocked writers on Windows (battle-test #259): the store’s ownopen()held a share lock, so a reader in a loop exhausted the writer’s retry budget ->PermissionError. Readers useFILE_SHARE_DELETE; a refused rename falls back to POSIX-semantics rename.-
Damaged store records raised bare exceptions (battle-test #259): a text or negative
version, NaNupdated_at, malformed deadline row, a BLOB snapshot, a directory where the record file should be, a non-SQLite file, a foreignstatechartstable –ValueError/TypeError/OSError/sqlite3.DatabaseError, all escapingexcept StoreError. Every one is nowSnapshotCorruptErrororStoreError(the SQLite constructor included). Codec failures, lone-surrogate keys/snapshots and badsave()arguments are typed identically on all three backends;list_keysskips keysloadwould refuse; a crashed writer’s.tmp-*files are swept at construction. -
The
[web]extra was empty (battle-test #258).pip install "xstate-statemachine[web]"installed nothing, although every member it was documented to bundle (FastAPI, Django, DRF, Flask, SQLAlchemy) had shipped. It now equals that union; a test pins[web],[eda]and[all]to the unions their comments name. Also from the same pass: the[format]floor saidblack>=24.0, a version PyPI never published (Black 24 starts at 24.1.0); thecontribregistry listed onlylitestarfor the Litestar extra although the package also requiresstarlette, so the extras-matrix blocking test never blocked it;xstate_statemachine.exceptionshad no__all__; the Integrations pages still called the shipped broker adapters “planned” and never said whenMissingExtraErroris raised (at import for subpackages; on first use for the structlog / loguru / Sentry / LangChain plugins and the Hypothesis helpers). Every extra’s floor is now checked against PyPI for existence and a 3.9-compatible file, and[all]is dry-run installed as one set. -
Competitor benchmark numbers were not like-for-like (battle-test #307). Our parallel-regions adapter sent one event per iteration where every other adapter sent two, inflating the published “7.5x” over
transitionsto exactly double its true value – it is 3.8x. python-statemachine’s nested adapter sent three events per iteration where the others send two, understating it by 1.5x – our “10.3x” is 7.0x. Construction and 1 000-instance rows, published as 1.19x and 1.10x wins, are a tie within +/-3 % over six interleaved runs on 0.11.0. README, landing page,benchmarks/competitors/README.mdandresults.jsoncarry the corrected numbers. - Security (battle-test #303, X0 baseline). An adversarial pass over
every X0 row, both engines, with the attacks pinned as tests in
tests/test_battle_303_x0_core.pyandtests/test_battle_303_x0_integrations.py(named in the X0 table on the Security page). Findings, most severe first:- Idempotency scope pooled unauthenticated callers (HIGH, X0.1/X0.2).
A
principal=callable returningNoneor""became the shared scope"None"/"", so every such caller could replay every other’s receipt. The principal must now be a non-emptystr; anything else is refused with a receipt, never pooled. The independent review found the web adaptersstr()-coerced the principal first (None->"None"), so the rule is enforced at the adapters too – see Changed. - Idempotency scope join was not injective (HIGH, X0.2). Principal
alice/c+ machineccollided with principalalice+ machinec/c. Each scope part now escapes%and/; parts without them are unchanged, so existing inbox rows keep their scope. send()options accepted from a client JSON body (HIGH, X0.7).{"priority": true}posted to the Starlette / FastAPI / Litestar / Flask / Quart / WebSocket send routes really did jump the queue, and{"wait": false}produced a 500TypeError. A body naming a reservedsend()key is now 422ReservedKeyError; Django’sRESERVED_PAYLOAD_KEYSgainspriority.FileStoreparsed before it checked the size cap (X0.4). The read is now bounded beforejson.loads; an 8 MB file of spaces is refused in well under a second without ever being parsed.- Deep nesting / non-UTF-8 escaped as bare exceptions (X0.4). A
100 000-deep
[[[...]]]raisedRecursionError, non-UTF-8 bytesUnicodeDecodeError, a BLOB rowAttributeError– all from insideexcept XStateMachineError. Each is nowSnapshotCorruptError. SQLiteStore.forget()left a co-located transition log (X0.5). Log rows for the key are now deleted in the same transaction; the result dict gainslog_entries.redact()missed hyphenated header keys (X0.5).x-api-keyandApi-Keypassed through because the denylist is spelled with_; keys are now normalised before matching.
- Idempotency scope pooled unauthenticated callers (HIGH, X0.1/X0.2).
A
- A context that cannot be deep-copied no longer kills the run loop
(battle-test #305). Receipts,
on_event_processedand thecontext_validatordirty check all took a rawcopy.deepcopy(context)before-image. A context holding a lock, socket or client made the asyncsend(wait=True)hang forever (the exception killed the loop, status went"stopped", the receipt never resolved), made the syncsend(wait=True)raiseTypeError, and made every action raise once anycontext_validatorwas configured. Both engines now go through_context_before_image()/_context_changed(): an uncopyable context is treated as “changed”, so receipts resolve and the validator runs.actionErrorPolicy: "rollback"/"fail"still need the copy and still raise – documented on the Context page. SyncInterpreter.send_threadsafe()can no longer lose an event silently (battle-test #305). Three cases were silent: an event posted afterstop()sat in the mailbox forever; events still queued whenstop()ran were discarded; events queued behind one that drove the machine tofinalin the same drain were thrown away by the finished machine. Each now fireson_event_dropped("not_running"/"stopped") and logs a warning. A producer’s event is always either run or reported.-
A snapshot whose
versionisInfinityraised a bareOverflowError(battle-test #305).json.loadsacceptsInfinity/NaN;int(inf)overflows.check_versionnow maps it toSnapshotCorruptError, closing the last gap found by flipping, truncating and inserting every byte of a v4 blob on both engines. -
stub_logic()missed composite guards written inchildrenform (battle-test #304).{"type": "and", "children": ["c1", ...]}is accepted by the engine, but the stub read names from the raw dict with the CLI extractor, which only knowsparams.guards– so the leaf guards were unstubbed and the first send raisedImplementationMissingError. Names are now read from the parsedMachineNode. In the same change an invalid config passed tostub_logicraises the exactInvalidConfigErrorcreate_machinewould, instead of a misleadingTypeErrorfromdict(...)or a silent empty stub. - The nightly perf job went red on a runner with a different CPU model.
Hosted
ubuntu-24.04runners are not pinned to one CPU: a run that landed on an EPYC 7763 measured every row a uniform ~1.33x over a baseline recorded on an EPYC 9V74 – different silicon, not a regression.tests/test_perf_budgets.pynow skips the budget rows (naming both CPUs) when the runner’s model differs from the reference, so a red nightly is always a same-hardware regression. - A parent snapshot can no longer harvest a non-blocking sync child’s
half-applied context. “Wait until the child is settled” and “copy its
context” were two separate reads; the child’s pump thread could begin a
step between them (a deterministic interleaving reproduced it even via
the supported
send_threadsafe()path), and CI saw it as “1 torn of 150”. The pump now runs each step under a per-child step gate that the parent holds across the check and the copy; a gate not free within 0.5 s refuses withSnapshotMidStepError(child=True). The async engine needs no gate (its children step on the caller’s loop). A foreign-threadsend()on aSyncInterpreterremains unsupported and voids the guarantee – usesend_threadsafe(). - Opening a second
SQLiteStorehandle on a file another connection was reading failed withdatabase is locked.PRAGMA journal_mode = WALis persistent in the file but issuing it needs an exclusive lock, so a CLI (xsm dlq) or a second process opening a live store could not even connect. The mode is now switched only when it differs from the file’s, and a switch refused by a lock keeps the file’s mode with aRuntimeWarninginstead of failing to open. - One
afterdelay with several guarded candidates armed one timer per candidate. All candidates under"1000": [...]share the eventafter.1000.<state>and guard selection happens when it is processed, so N identical events were queued at the same instant; when the winner re-entered its own state the extra copy fired against the freshly re-entered state in the same pump – anudgere-entry counted twice per second. One timer per delay now, on both engines. Found by the slot-filling recipe (#308). SyncInterpreter.send(..., wait=True)on a finished or stopped machine now returns aReceiptcarryingInterpreterStoppedError, exactly as the async engine does – notNone, which thewait=True -> Receiptoverload never promised and which made the[flask]blueprint answer a POST to a completed order with a 500. The corereceipts.receipt_to_statustable (and the Starlette/FastAPI/Litestar layer, which now defers to it for error classes) maps that receipt to 409 – the instance refused the event, like a guard – instead of 500. Fire-and-forgetsend()still returnsNoneand fireson_event_dropped.- Idempotency refusals over HTTP are RFC 9457 problems. A reused
Idempotency-Keywith a different body (422) or a still-in-flight request (409) returned a receipt body with a 4xx status andapplication/json; every other 4xx was alreadyapplication/problem+json. They are problems now (title,status,errorclass name – never exception text). A plain replay still returns the original receipt withduplicate=True. Found by driving the installed wheel over a real uvicorn server. --with-testsoutput could not find its JSON when generated into a subdirectory (xsm gt machine.json --with-tests -o generated/, the layout the docs recommend): the scaffold looked only beside the test module and failed withFileNotFoundErroron the first run. It now looks beside the module first, then one level up – the lookup the runner template already used. Found by installing the wheel into a clean venv and running the generated project.- Bare-string
and/or/notguard names are user predicates (only the dict form declares composition), but the CLI’s guard parser dropped them andcamel_to_snakestripped the_suffixsafe_identifieradds for Python keywords – sostub_logic(raw_config)missed a guard the engine demanded (ImplementationMissingErroronDebtState_v4.json) and the typed template emitteddef and(...), a SyntaxError. Both fixed; generated names for keyword guards are nowand_/not_(#269).
Tests
- The
raise(delay=)/afterheartbeat parity tests no longer depend on wall time.TestDelayedSelfSendIsATimerran a 30 ms heartbeat for 1.0 s of real time on aRealClockand asserted a tolerance band on the beat counts, which flaked once on a box running two other suites. Both engines now run on aSimulatedClockadvanced in 10 ms steps, so the counts are exact (34 / 11 / 5 beats for 30 / 100 / 250 ms over 1000 ms) and the two idioms are asserted equal, not close. A sync-engine parity case was added on the same clock. TheRunawayChainErrorcase is unchanged – it never depended on time.
[0.10.5] - 2026-09-25
Fixed
Findings from a manual pass over every xsm command against the example
corpus.
- Launcher → Simulate read from its own keyboard, not the launcher’s.
run_simulatedecided live-vs-scripted fromconsole.interactiveand opened the real key reader itself, so the flow could not be driven by the launcher’s injected key source (and was untestable end to end).run_simulate(..., source=)now threads the launcher’s source through; a launcher test drives Simulate → send → undo → quit. - Multiselect
spacetoggled without advancing, so the natural “space, space, enter” in the generate wizard’s Companion files step toggled the first row on and off again and emitted no companions.spacenow toggles and moves to the next row (Inquirer / fzf convention);Home/Endwork in multiselect as they already did in select. xsm docs machines/*.jsonstopped at the first broken file. It now reports that file, documents the rest, and exits 1 at the end.
[0.10.4] - 2026-09-24
Fixed
- Ctrl+C in the launcher or simulator printed a Python traceback. The
raw-mode key reader deliberately raises
KeyboardInterruptonCtrl+Cso the interactive prompts honour it, but nothing at the entry point caught it.main()now restores the cursor (a prompt or spinner may have hidden it), printsinterruptedto stderr and exits with status 130, the conventional SIGINT exit — no traceback, like every other terminal program.
[0.10.3] - 2026-09-24
Fixed
xsm simulatelooked frozen on a chart with no sendable events. A machine whose transitions are allalways/after(e.g. thequality_check_syncexample settles inpassedimmediately) has nothing for the event picker to offer, so the loop dropped straight into command mode — silently, under a↑↓ pick eventhint. Arrow keys did nothing and it read as broken input. Command mode now prints a❯ no event can be sent from hereline with the command keys, and↑/↓/enterthere explain why and point att/c/u/r.
[0.10.2] - 2026-09-24
Fixed
xsm updatefailed on Windows when run throughxsm.exewithWinError 32: The process cannot access the file because it is being used by another process: '...\Scripts\xsm.exe', leaving the package uninstalled but the launcher present. pip’s launcher is the parent of the update process and stays alive until it exits, so pip cannot delete it (pip has the same constraint with itself — hencepython -m pip install --upgrade pip).updatenow detects that it was started via the launcher (sys.argv[0]isScripts/xsm[.exe]next to this interpreter) and hands over to a detachedpython -m xstate_statemachine update --yes, which runs pip after the launcher has exited; the header is printed once and the child prints pip’s output and the result. Verified end to end from PowerShell,cmdandpython -min a fresh venv upgrading 0.10.0 → 0.10.1.
[0.10.1] - 2026-09-24
Fixed
- Launcher: pasted Windows paths were reported “not found”. Explorer’s
Copy as path wraps the path in double quotes (and a dragged file may
carry single quotes); the file picker handed the quoted string to
Path()verbatim, which looked for a file literally named"C:\…". Every path prompt in the launcher (machine files, output directories) now normalises its input: surrounding quotes are stripped,~is expanded, afile:///URI is accepted, and typing a directory selects its*.jsonfiles. Driven end to end through the launcher with the quoted form in the tests.
[0.10.0] - 2026-09-24
A CLI release. The runtime library is unchanged apart from one additive
overload; xsm grows from a code generator into a terminal toolkit — with
the same zero runtime dependencies as the library. Colour, box-drawing,
spinners, single-key input and Windows VT enablement are all standard
library. When stdout is not a terminal (or --plain is given) every command
emits deterministic plain text, so existing pipelines and CI logs are
unaffected.
Added
- Interactive launcher. A bare
xsmon a terminal opens a menu (arrows or digits,enter,esc) over every command, with recently used machine files remembered in~/.xsm/recent.json($XSM_HOMEoverrides) and a generate wizard that picks files, template, companions and options, shows the head of the module it is about to write in a preview panel and asks before writing. The wizard builds the sameargparse.Namespacethegtcommand uses, so nothing is implemented twice. Off a terminal, a barexsmstill gives the argparse “subcommand required” error. xsm inspect(ins). Builds the machine with the real library and lays out a summary panel, the state tree (kind glyphs,initial,afterandinvokeannotations), a transitions table (event, from, to, guard as the library resolved it, actions), the logic left to implement and the failure-policy block (actionErrorPolicy,guardErrorPolicy,onUnhandled,maxIterations,strict,strictTargets, event schemas).--no-eventsskips the table;--jsonemits the facts.xsm simulate(sim). OneSessionengine —SyncInterpreteron aSimulatedClockwith stub logic, guard overrides, a snapshot undo stack and a step history — drives two modes. Live (on a terminal): a picker of the eventscan()says would do something now, thentfire the armed timer,cadvance the clock,gtoggle guards,uundo (timers re-armed viafrom_snapshot(restart_timers=True)),rreset,hhistory table,ssnapshot,qquit; changed states pulse. Scripted (--events A,+500,B,--clock,--script file.jsonwith{"send"}/{"clock"}/{"guard","value"}/{"undo"}/{"reset"},--guards-false a,b,--json, or simply no terminal): replays the commands, prints each step and the final state, exits 0/1. The JSON document carriesactive,value,context,clock_ms,enabled_events,chain_tripsand a per-stephistory.xsm diagram(dia). Mermaid (to_mermaid), PlantUML (to_plantuml) or an ASCII tree-plus-transition-list, to stdout or-o FILE|DIR(named<machine-id>.mmd/.puml/.txtin a directory).xsm docs. A Markdown reference page per machine — summary, embedded Mermaid, state tree, transitions, logic to implement, policies and a getting-started snippet — to stdout or-o DIR.- Companion templates
pytest,typed,plugin, usable as--templateon their own or alongside any primary template via--with-tests/--with-types/--with-plugin:pytest→test_<machine>.py: a test module recorded from the engine — the chart is run with stub logic on aSimulatedClockalong its reachable event sequence and the configuration and actions after every step become assertions; plus initial-state, final-state, guard-denial, known-events and snapshot-round-trip tests. Runs green on day one and fails only when the chart (or the library) changes. The generated suites are themselves executed against the Stately corpus in the test-suite.typed→<machine>_types.py:Contextas aTypedDictinferred from the JSONcontext,EventType/StateIdLiteralaliases, one correctly annotated stub per action, guard and service, and alogic()binder.plugin→<machine>_observer.py: aPluginBasesubclass overriding exactly the hooks the chart can fire (on_service_*only withinvoke,on_guard_erroronly with guards,on_transition_failedonly under a rollback/fail policy, …), each writing one JSON log line; a trailing comment lists the hooks left out and why. Companions are compiled and import-checked before writing, carry the provenance banner and participate in--check/--diff.
- Presentation flags, accepted before or after the subcommand:
--plain,--no-color(alsoNO_COLORandTERM=dumb),--no-anim(alsoXSM_NO_ANIM=1),--verbose(INFO log on stderr; default is warnings only). Truecolor whenCOLORTERM=truecolor|24bit, else 256/16 colours; Windows consoles get VT processing enabled. Unicode glyphs only when the stream can encode them; on an ASCII console separators and arrows are transliterated (->,-,...) instead of becoming?. --jsononvalidate,inspect,simulate,list-templatesandinfo.xsm update— checks PyPI (stdliburllib, 10 s timeout) and upgrades to the latest release with the installer that installed this copy:pip(incl.uv pip),pipx upgrade,uv tool upgrade. Refuses an editable checkout (usegit) and a conda environment (prints thecondacommand) rather than corrupt them. Asks on a terminal, prints the command and exits 1 off-terminal without--yes,--checkexits 1 when a newer release exists,--jsonfor scripts. Confirms the result by asking a fresh interpreter for--version, and on Windows re-applies thesetupshim afterwards since pip recreatesxsm.exe. Also on the launcher menu.xsm setup— makes thexsmcommand work on Windows machines whose Application Control / AppLocker / Smart App Control policy blocks pip’s unsignedScripts\xsm.exelauncher (“An Application Control policy has blocked this file”) whilepython.exeis trusted. Run once aspython -m xstate_statemachine setup: it parks the launcher asxsm.exe.blocked(Windows resolves.exebefore.cmd, so it must move aside) and writes anxsm.cmdbatch shim that runs the CLI through the trustedcmd.exe→ the same interpreter. Idempotent; re-run afterpip install --upgrade(which recreates the exe);--checkreports the state (exit 1 ifxsmstill resolves to the exe),--undorestores pip’s launcher,--jsonfor scripts; a no-op with a message on other OSes. Verified on a WDAC-managed machine from PowerShell andcmd. The launcher is generated by pip on the user’s disk, so nothing in the wheel can sign or replace it automatically — this is the closest thing to a fix a package can ship.python -m xstate_statemachineruns the CLI (a top-level__main__.py;python -m xstate_statemachine.clistill works) — the launcher-free spelling that works on every machine where Python does.xsm infoprints it on anAlso run as:line and, on Windows, points atsetup.xsm validate --lenientdowngrades unknown config keys to warnings.create_machine(config, logic=..., strict_config=...)— the logic-object overload now acceptsstrict_configlike the other overloads (it was accepted at runtime but rejected by type-checkers).cli.uitoolkit (internal, stdlib only):Capabilitiesdetection, a role-based theme with colour tiers, ANSI-awarevisible_width/pad/truncate/wrap, boxes, tables (auto-shrinking columns, zebra rows), trees,Spinner/ProgressBar/StepListwith non-animated fallbacks,select/multiselect/confirm/textprompts driven by an injectable key source (keys.scripted("enter esc q")) so every interactive path is tested without a pty, a block-letter banner and aConsolefacade.
Changed
xsm validatenow builds each file throughcreate_machine(strict_config=True)with stub logic instead of a hand-rolled structural check, so it refuses exactly what the library would refuse at runtime — including misspelled keys with the library’s “did you mean” hints — and additionally reports states no transition,initialor history target can reach, plus every warning the library logged while building. The plain-text report keeps its previous shape (ok <file>,Machine:,States:, …,All N file(s) are valid.).xsm list-templatesgroups the catalogue (JSON-at-runtime, pure-Python, companions) and adds the companion rows to the feature table.xsm inforenders the banner, an environment panel and feature cards; the previousVersion:/Python:/ links lines are still present.- Library log noise. The CLI now shows the library’s log at WARNING by
default while it builds machines; pass
--verbosefor INFO. - Generated-code banner reports
Generator: xstate-statemachine 0.10.0.
Documentation
- The CLI Tool
guide is rewritten around the six commands, the launcher, the
presentation flags and the
--jsonoutputs; the Templates Deep Dive gains a companion-templates section with the generatedpytest,typedandpluginoutput for the checkout example; README CLI section and the getting-started “What’s New” updated.
Tests
- 145 new tests under
tests/tests_cli/cover the UI toolkit (rendering in truecolor / 256 / plain, width arithmetic on styled text, prompts driven by scripted keys), the platform readers and probes with fakes (msvcrt,ctypes,termios), the companion generators (including running the generated pytest suites in a subprocess), inspect / diagram / docs, the simulator engine, scripted and interactive modes, the launcher menu and wizard end to end, thesetupshim lifecycle in a temp directory on every OS, andupdatewith every I/O seam patched. Suite total 3,735 tests at 93% coverage.
[0.9.1] - 2026-09-24
Fixed
- Round-13 re-verification findings (#239–#248). Every bug
reproduced against
v0.9.0with the reporter’s standalone repro before the fix and pinned intests/test_round13_findings.py(24 tests, both engines where parity is the point).Interpreter.drain_pending()drains the priority lane too (#239). It read the inbox only, so fired timers, engine completions andsend_priority()events were omitted from the result and left queued, wherestop()cleared them — the documented “drain, persist, stop” recipe silently lost every deadline that fired just before shutdown. Both lanes are drained now, priority first (the orderpending_eventsreports); await=Truereceipt on a drained event is failed withInterpreterStoppedErrorinstead of hanging.on_interpreter_startfires on a restored interpreter (#240). Every resume branch ofstart()on both engines returned above the hook loop, so a lifecycle plugin saw astopwith nostart. The hook now fires on every path, once;interpreter.restored_from_snapshottells a plugin bring-up from resume.- Malformed
chain_trips/last_chain_errorareSnapshotCorruptError(#241). The #226 fields were read after the validator had passed the blob, so"NaN", a list or a dict escapedfrom_snapshotas a rawValueError/TypeErrorand broke theexcept SnapshotCorruptError: quarantineidiom.check_shapenow requires a non-negative integer (a numeric string is accepted;boolis not) and a string-or-null message. - The restored chain latch is a
RestoredChainError, which IS aRunawayChainError(#243). It also remains aRestoredError, so the live-machine guardisinstance(interp.last_chain_error, RunawayChainError)keeps firing across a restart instead of going silentlyFalse..limit/.droppedareNoneon a restored latch (JSON kept the message);chain_trips > 0remains the type-independent signal. The genericerrorfield is unchanged. - A dropped
wait=Truereceipt is observable deterministically (#244). The #232RuntimeWarningcomes from a finaliser, which CPython routes tosys.unraisablehook— invisible to-W errorandpytest.warns. New:Interpreter.dropped_receipts(a counter) andPluginBase.on_receipt_dropped(interpreter, event_type), both driven from the same finaliser, so a test or health check can assert on them under any warning filter. Transition behaviour is unchanged.
Added
events.re_mint(original, **fields)(#248) — the sanctioned way to change a field of an engine-minted event and keep engine provenance (redactdatabefore re-emitting, say). Gated on the input: it accepts only an event that alreadyis_system_event, so it can carry provenance forward but never create it._replace()stays a deliberate one-way demotion (#235) and the design note now says so.SyncInterpreter(max_queue_size=None, overflow_policy=None)(#245) for signature parity withInterpreter. The sync engine has no inbox to bound —send()runs each event to completion before returning — so a non-Nonebound raises a documentedValueErrornaming the alternative (admission control in the caller’s wrapper) instead of a bareTypeErrorfrom a missing keyword. The class docstring and the API reference state the asymmetry.benchmarks/production_characteristics.py --json/--json-file PATH(#246) emits one JSON object with the host description (library_version,python_version,platform,machine,processor,cpu_count,method) and every measured row, so a CI job can gate onlateness_msfor its own hardware instead of scraping the table. The guide’s “Measured on” line is that host block.
Changed
- Release provenance (#247). Releases are published through PyPI
Trusted Publishing (OIDC, no long-lived token — already the case since
0.8.0) and now carry PEP 740 build provenance attestations
(
attestations: true, Sigstore-signed), binding each wheel and sdist to the GitHub Actions run, commit and workflow that built it. Verify withpypi-attestations verify pypi --repository https://github.com/basiltt/xstate-statemachine <dist-url>(see the README Install section). - Docs (#242, #243).
snapshots.mdstates the latch’s type change in the same sentence as its restart survival, recommendschain_trips > 0for restart-safe guards, and spells out thatchain_trips/last_chain_errorare restored verbatim inside the #205 trust boundary — a party who can write the blob can manufacture or suppress a chain-trip alert.
[0.9.0] - 2026-09-23 — Adopted
Naming, and the site.
Fixed
- Round-12 re-verification findings (#225–#235). Every one
reproduced against
main@f4067b6with the reporter’s standalone repro before the fix and pinned intests/test_round12_findings.py(31 tests, parametrised overdef/async def, both engines where parity is the point).- Self-send provenance is decided by task identity, not an inherited
context (#225). The #105 “issued from one of my actions” predicate
rode a
ContextVar, andasyncio.ensure_future/create_taskcopy the context, so a helper task spawned from an action was treated as the action for its whole life: its latersend(wait=True)was refused by the #219 guard, and its plainsend()was routed to the internal queue — drained only inside a macrostep — so with the loop idle the event sat there and the machine never advanced. The documentedensure_future(i.send(..., wait=True))escape hatch also flipped to a refusal whenever the spawning action awaited again afterwards. Both predicates now ask “is the current task running one of my actions?” (_action_tasks, per task with a nesting depth); theContextVaris gone. A worker that outlives its action, and the hand-out idiom whether or not the action yields, are ordinary external traffic; the genuine in-step await is still refused. - A
defaction that drops itswait=Truereceipt is told so (#232). A synchronous action cannot await, sor = i.send("B", wait=True)handed it the #219 guard object and nothing ever said so. The object now emits aRuntimeWarningif it is finalised without ever being awaited or handed out — exactly as CPython does for a never-awaited coroutine.ensure_future(...),.add_done_callback,.result()andawaitall count as use, so the supported shapes stay silent. - The chain-trip latch survives a snapshot (#226).
chain_tripsandlast_chain_errorare v3 envelope fields (additive; old blobs upcast to0/None). A restore reports the same count and aRestoredErrorcarrying the message — theerrorprecedent — andclear_chain_error()remains the only thing that clears it. The counter stays monotonic across the restart. strictand event schemas apply to restoredscheduled_sends(#227)._rearm_restored_self_sendsre-armed every record unchecked, so an undeclared type in that lane was admitted silently on astrict: Truemachine while the same record inpending_eventswas refused (#214). Both lanes now go through_admit_restored; the refusal fireson_invalid_eventand lands onlast_error; the function returns how many were armed. A schema refusal (InvalidEventPayloadError) on either lane is now caught and reported the same way instead of aborting the whole restore.from_snapshot(plugins=...)(#230). Plugins are registered before the persisted events are admitted, so a restore-time refusal reacheson_invalid_eventlike a runtime one. Same effect as.use()on the result, just early enough; the parameter is optional.- The sync engine honours the priority lane on restore (#233).
SyncInterpreter._enqueue_restoredacceptedpriorityand ignored it; alane: "priority"record now restores at the head of the single queue (FIFO within the lane, ahead of the inbox), the order the async engine’s two lanes give. - An inline-dict
invoke.srcis refused by name (#231). The XState-JS inline-machine shape died asTypeError: unhashable type: 'dict'insidelogic_loader. The parser now raisesInvalidConfigErrornaming the state, the invoke id, the type it got and the supported alternative (build withcreate_machine, register inMachineLogic(services=...)). - Engine mint helpers are private;
_replacedemotes to the public class (#235).events.engine_done/engine_error/engine_afterare renamed_engine_*; the unprefixed names remain asDeprecationWarningshims until 1.0._replace()on an engine-minted event returns a plainDoneEvent/ErrorEvent/AfterEventwithis_system_event(...) == False: a caller-chosen variant is user traffic.pickle/deepcopystill preserve provenance (they reproduce the same value). The engine’s ownfired_atstamps re-mint. - Test quality (#228). The
nested_invokelap-parity shape intests/test_round9_findings.pynever exited its initial state and fired exactly two calls at everymaxIterations— a constant agreeing with itself. It now re-enters the outer state (call countmi + 3, trips the guard, agrees on all three lanes). Every other limit sweep in the round pins (rounds 3, 5, 8, 9, 10) was audited: each asserts aRunawayChainErroror a limit-derived landing state, so none is inert.TestLivelockPinsAreLimitDependentasserts, for each shape the sweep uses, that the count differs between two limits and at least one trips — so a future inert shape fails CI. - Docs (#229).
interpreters.mdstates what the hand-out idiom requires and that a spawned helper may talk back to the machine;snapshots.mdand Production Characteristics § 2 record that the chain-trip latch now crosses a restart.
- Self-send provenance is decided by task identity, not an inherited
context (#225). The #105 “issued from one of my actions” predicate
rode a
- Round-11 re-verification findings (#218–#222). Every one
reproduced against
main@c78ce99with the reporter’s standalone repro before the fix and pinned intests/test_round11_findings.py(23 tests, parametrised overdef/async def, both engines where parity is the point).- A delayed self-send releases its clock handle when it fires or is
cancelled (#218). The handle was registered under the interpreter
id (so
stop()could clear it) but the only pruner ran on state exit, so the list grew by one dead handle per beat for the life of araise(delay=)heartbeat — unbounded now that #212 made such cycles legal. Both engines; a 200-beat heartbeat holds at most one handle. - An action that awaits
send(..., wait=True)on its own interpreter getsReentrantWaitError, not a deadlock (#219). The receipt resolves only when the run loop processes the event, and the loop cannot advance until the action returns; #215’s descent gate made the hang reachable fromstart(). The receipt can still be handed out (asyncio.ensure_future(i.send(..., wait=True))) and awaited later, from any other task; only an await performed by the action’s own task, while it runs, is refused (predicate narrowed in #225). The sync engine refuses the same shape for parity (there the receipt would have described the wrong step). - Unknown config keys are checked in every state, transition and
invoke, not only at the root (#220). #216 iterated the root dict
only, so
{"states": {"a": {"entyr": [...], "onn": {...}}}}built a clean machine with no entry action and no transition, with no WARNING, under every strict setting. The check now recurses (nestedstates, parallel regions,on/always/after/onDonetransition bodies,invokeentries and theironDone/onError) with a per-level known set —KNOWN_ROOT_KEYS,KNOWN_STATE_KEYS,KNOWN_TRANSITION_KEYS,KNOWN_INVOKE_KEYS(KNOWN_MACHINE_KEYSis kept as an alias of the root set). Findings name the path (m.r2.y.z: 'tpye' (did you mean 'type'?)); one WARNING or oneInvalidConfigErrorlists them all.x-keys andmeta/description/tagsare accepted at every level. - Restore → re-persist without
start()keeps armed delayed self-sends (#221).from_snapshotparked the v3scheduled_sendsrecords forstart()to re-arm, butget_persisted_snapshotread only the live armed set — so a journal-compaction or migration job that loads and rewrites blobs without starting a machine silently dropped every deadline it touched. The parked records are now re-emitted verbatim untilstart()consumes them. - A chain-budget trip is sticky (#222).
last_erroris recomputed per processed event, so one benign handled event erased the only record that the machine had discarded work — and post-#212 a heartbeat guarantees such an event arrives. New on both engines:interpreter.chain_trips(monotonic count),interpreter. last_chain_error(a latch, cleared only byclear_chain_error()) andPluginBase.on_chain_budget_exceeded(interpreter, error, event), fired once per trip (settle-budget trips included).last_erroris documented as the per-step read it always was.
- A delayed self-send releases its clock handle when it fires or is
cancelled (#218). The handle was registered under the interpreter
id (so
- Round-10 re-verification findings (#212–#216). Every one
reproduced against
main@19cb1f1with the reporter’s standalone repro before the fix and pinned intests/test_round10_findings.py(15 tests, parametrised overdef/async def, both engines where parity is the point).- A delayed self-send is a timer, not a chain (#212; supersedes the
#206 rule). #206 charged a
raise(delay=)self-send as a debt of the arming step; #212 showed that killed every self-paced heartbeat or poller atmaxIterationsbeats regardless of period — the charge was time-blind. The rule is now theafterrule: arming a delay ends the step’s chain, the firing is a clock event. Araise(delay=)heartbeat of any period runs indefinitely, exactly as anafterone does, and a 1 msraise(delay=)ping-pong is a periodic process exactly as anafter: 1ping-pong has always been.maxIterationsbounds work the machine feeds itself within a step (zero-delayraise, self-send, completions re-arming invokes) — stated injson-config.md, Production Characteristics § 2 and the round-9 pins, which are rewritten to the new rule. - An armed, unfired delayed self-send survives a snapshot (#213).
It existed nowhere the snapshot could see and was silently
discharged, so a state whose only exit was a delayed self-raise
restored permanently parked. Snapshot layout v3 adds
scheduled_sends: each armed self-send with its remaining delay and id;start()re-arms them, on both engines, with the standing they had. Cancelled sends leave no record. - The restore path applies
strict, keeps legacyafterdeadlines, and restores the priority lane as a lane (#214). Restored user events pass the samestrictcheck asend()does; a refusal is reported (on_invalid_event,last_error) and the event dropped. A v2 (0.8.0-era)done/error/afterrecord is upcast as engine-minted — only the engine could have written one — so a persisted deadline still fires rather than being silently demoted by #203’s gate; a v3 record without the flag stays user traffic. Records carrylane, so a fired timer restores ahead of the inbox. - Three-lane lap parity on an engine-work-only chart (#215). Two
async-only defects: the settle budget reset on a self-raised plain
event (the sync drain keeps it for anything the machine generated),
and the run loop consumed the initial descent’s own
raisewhile anasync defentry action was still yielding — interleaving two macrosteps the sync engine’s re-entrancy guard forbids. The reset now keys on external provenance; the loop waits for the descent to settle; descent-queued raises get seed standing.always+ zero-delayraiseagrees on all three lanes at limits 1–25, pinned as a sweep. - Unknown top-level config keys are caught (#216). A misspelled
policy key (
actionErrorPolicyy,Strict,maxIteration,onUnhandledEvent) was silently dropped and the policy reverted to its permissive default. Default: WARNING with a “did you mean” hint;create_machine(strict_config=True)or config-level"strictConfig": true:InvalidConfigError.x--prefixed keys andmeta/description/tags/versionare always accepted.KNOWN_MACHINE_KEYSis the single list.
- A delayed self-send is a timer, not a chain (#212; supersedes the
#206 rule). #206 charged a
Changed
- Snapshot layout version 2 → 3 (#213, #214): adds
scheduled_sends, per-recordengineprovenance andlane. v2 payloads upcast transparently;from_snapshot(minimum_version=3)refuses anything older.
Fixed
- Round-9 re-verification findings (#203–#210). Every one
reproduced against
main@f28719cwith the reporter’s standalone repro before the fix and pinned intests/test_round9_findings.py(19 tests, parametrised overdef/async def, both engines where parity is the point).invokeruns after eventless transitions settle (#204; SCXML §6.1statesToInvoke). Entry recorded the state; the settle pass, once stable, arms invokes for the recorded states still active. A state entered and exited within one macrostep — rolled forward by analways, rolled back byactionErrorPolicy— never submits its service, on either engine and for either service kind. This closes the roll-forward half of #193 that had landed on one engine only.aftertransitions match on provenance (#203). #195 minted_EngineAfterbut selection still matched the publicAfterEventclass, so a hand-built event or a forged snapshot record fired a 60-second timer instantly. Only an engine-mintedAfterEventdrives anaftertransition, asdone/erroralready required.- A delayed self-
sendis self-generated work (#206) — superseded by #212 in round 10. #206 made araise(delay=)self-send a debt of the arming step so a 1 ms ping-pong would trip; #212 showed that rule was time-blind and killed every self-paced heartbeat atmaxIterationsbeats. The shipped rule is theafterrule (see the round-10 entry): a delayed self-send is a timer, its firing is a clock event, and a delayed ping-pong of any period is a periodic process — as anafter: 1ping-pong has always been.maxIterationsbounds work generated within a step. - A chain cut that strands an invocation is observable (#207). A
rollback + onDonestorm cut atmaxIterationsleft the machine parked in the invoking state with nothing running and no completion that could ever arrive, distinguishable from “waiting on a slow service” only by polling the configuration against the chart. Both engines now name it:RunawayChainError.strandedcarries the invoke ids, the newon_invocation_stranded(interpreter, state_id, invoke_id, error)hook fires, an ERROR log names the state, andhas_dormant_invocations/pending_invocations()answer on demand. The reporter’s “self-terminates below the limit” reading was the plateau sampled at 0.45 s while thedeflane was still climbing; the cycle trips at exactlymaxIterations + 2on both lanes. - A receipt is never success-shaped over an empty configuration
(#208). Receipts are resolved after the in-flight flag is down, so
the reported coincidence with a
SnapshotMidStepErrorcannot occur on this tree (the repro’s own run shows 0 hits); the receipt path now also refuses to reportokwhen the step ended with an illegal configuration, as belt and braces. - Lap parity, stated exactly and pinned as a sweep (#209). The
sync engine ran two laps more than async at every odd limit on
rollback + onDonebecause the initial descent’s settle work was charged against the seed chain on the async engine only. The seed’s standing now covers the settle budget too; all three lanes agree at limits 1–25, odd and even, on both shapes. The #201 changelog sentence is corrected. TestAsyncRollbackRearmCycleBoundedwaits for convergence (#210) instead of sampling at fixed 0.6 s / 0.9 s, and asserts the exact plateau (maxIterations + 3, identical to the sync engine).
Added
from_snapshot(minimum_version=0, expected_machine_hash=None)(#205). A snapshot is trusted input by contract;machine_hashis a fingerprint against accidental drift, not a MAC. A caller who does not trust the payload can refuse a version-0 downgrade (SnapshotVersionError, new.minimumattribute) and compare the fingerprint against a value they hold (SnapshotDriftErroron mismatch or absence, regardless of version), so the payload cannot select its own level of checking. The trust boundary is stated in thefrom_snapshotdocstring, the API reference, the snapshot guide andstructure_hash.PluginBase.on_invocation_stranded(#207) andRunawayChainError.stranded.
Fixed
- Round-8 re-verification findings (#192–#201; reopened #181, #186).
Every one reproduced against
main@6db65d8with the reporter’s standalone repro before the fix and pinned intests/test_round8_findings.py(29 tests, every service/action test parametrised overdef/async def, both engines where parity is the point).- The priority lane sheds by provenance, not position (#192). #180
taught the charge site who issued an event; the shed site still cut
whatever sat at the head of the FIFO when a chain tripped, so an
external
send(priority=True)could be destroyed aschain_budgetby a runaway it had nothing to do with, and a priority send issued from an action was never charged at all. Each lane item now carries its provenance; only self-generated items are ever shed, and an action-issued priority send is charged like araise. - A
defservice is unwound by rollback and roll-forward (#193). The executor handoff moved from arm time into the task the engine holds, so a transition that arms the invoke and is then rolled back (actionErrorPolicy: "rollback") or rolled forward (analwaysout of the state) cancels it before the callable is submitted — as a coroutine service’s task is never started. A result that arrives for an exited state is ignored (SCXML §6.4.2). The cancellation half of the report is the documented plain-defcontract on both engines — the entering step awaits the service, so an event cannot pre-empt it — and Production Characteristics § 2 now says so;async defremains the interruptible kind. children_timeoutis per child and always reports an overrun (#194; reopened #181). The bound was aggregate and its WARNING sat on the same timeout path, so a non-yieldingdefentry action both defeated the bound and silenced the warning. N coroutine children settle in ~D; the WARNING fires whenever the allowance was exceeded, including the single-threaded case a bound cannot pre-empt — which the docs now state.DoneEvent/ErrorEvent/AfterEventcarry provenance (#195). They were public NamedTuples trusted on a bareisinstance, so a hand-builtDoneEvent("done.invoke.fill", ...)bypassedstrictandonUnhandledand drove a realonDonewhile the genuine service was still running — in-process, or reconstituted from a snapshot record byrestore_event(). The engine now mints private subclasses (_engine_done/_engine_error/_engine_after, private since #235),is_system_eventrequires them, a user-built one is refused understrictwith a message naming it as an engine-generated name, and persisted completions carry"engine": trueso a genuine round-trip keeps its provenance while a forged record restores as user traffic.- An
alwaysnever competes for a named event (#196). Eventless transitions were eligible candidates for named events, and a deeperalwaysoutranked a shallower handler, so under a spinningalwaysevery external event was consumed by a transition unrelated to it and its own actions never ran — withlast_errorclean. Eventless transitions are now selected only in the eventless settle pass (SCXML §3.13); the settle trip is reported on every step it affects. The reporter’s parity gap was #179’s inbox-lane reset, closed in round 7. Surfaced alongside:SyncInterpreterdid not stop the child actor an exited state’sinvokehad started (the async engine has since #43), so the re-entering cycle leaked one pump thread per lap; it now keeps the same owner map and reaps invoked children on exit and onstop(). - #197 pinned.
send(wait=True)never resolves over an empty configuration on either engine or lane (property test, 15 laps × 2 kinds × 2 engines); the window closed with #179/#182. - Versioned payloads carry both configuration fields (#198; reopened
#186). The agreement rule short-circuited on an empty field, so
emptying
state_ids— a strictly simpler mutation than contradicting it — let a forgedconfigurationrelocate the machine. On aversion >= 1running snapshot both fields must be present and non-empty; v0 payloads keep thestate_ids-only shape. on_interpreter_startis inside the in-flight window (#199). The hook fired a few lines before #182’s guard was raised, withstatus="running"and no configuration; a snapshot from it was the exact torn shape #182 closed. The flag is up before the hook on both engines.- The owed-completion ledger is task-keyed and leak-free (#200). A
bare counter was decremented by whichever completion arrived next and
leaked when a service task ended with a
BaseExceptionthat was neitherExceptionnorCancelledError. Each debt is now the owing task itself, settled by that task’s done-callback on every terminal outcome; one invocation’s completion can never pay another’s debt. - Lap parity, stated exactly (#201). The round-7 sentence “both
service kinds trip at the same lap count as the sync engine” was one
lap off for a cycle starting from the initial state (22 vs 23): the
first completion the initial descent produces is the chain’s seed
(user standing, the sync drain’s #77 rule), not a link. Fixed; all
three lanes agreed on the shapes tested at the time. (Round 9, #209,
found the sync engine two laps ahead at every odd limit on the
rollback + onDoneshape; with invokes armed at the end of the macrostep (#204) and the initial descent’s settle work given seed standing, all three lanes now agree at every limit, odd and even, on both the ping-pong and rollback shapes – pinned as a sweep.)
- The priority lane sheds by provenance, not position (#192). #180
taught the charge site who issued an event; the shed site still cut
whatever sat at the head of the FIFO when a chain tripped, so an
external
- Round-7 re-verification findings (#179–#190; reopened #167, #168,
#175). Every one reproduced against
main@221ce7cwith the reporter’s standalone repro before the fix (all twelve exited 1) and pinned intests/test_round7_findings.py(37 tests). Every test that involves a service or an action is parametrised overdef/async defand runs both engines where parity is the point — the round-6 pins were spelleddefonly and were structurally blind to the coroutine lane; the #167/#168 pins are retrofitted the same way.- One charged lane for every completion (#179; reopened #167/#168).
An
async defservice’sdone.invoke(and an invoked child’s terminal, anderror.platformon every path) was published viasend()onto the public inbox, where it was never charged to the chain budget and, arrivingfrom_inbox, reset the settle budget on every lap — somaxIterationswas inert for any machine whose services wereasync def, the style the docs recommend, while the identicaldefservice tripped. All completions now go through_publish_completion→ the priority lane, marked as engine completions and charged. A step that armed a coroutine service keeps its chain open until the completion lands (_chain_owed); a step that armed nothing still ends the chain, so a long service beside independent traffic never accumulates. Both service kinds now trip at the same lap count as the sync engine. - External
send(priority=True)is never charged (#180). The priority lane charged whatever arrived while a step was open — provenance by timing. A producer whose send landed mid-macrostep lost ~50% of its events aschain_budget, the failure #105 fixed on the inbox lane resurfacing here. Accounting is by who issued it: only engine completions and self-raised events count. start()bounds the wait for invoked children (#181).start(children_timeout=), defaultDEFAULT_CHILDREN_TIMEOUT(2 s); a slow child’sasync defentry action no longer holdsawait start()for its whole duration. On timeout a WARNING is logged,start()returns with the machine running, and the child registers when its bring-up completes.- The in-flight flag covers
start()(#182) and every action hook (#187). The initial descent runs entry actions and writes context, but_processingwas leftFalse, so a snapshot from an initial entry action was accepted and torn on the async engine while the sync engine refused it. The flag is set for the whole descent and cleared in afinally.on_action_executeis inside the refusal window on both engines. - A child mid-step is never harvested half-applied (#183), and the wait
never spins the event loop (#184). The child branch tested
configuration legality, which is true inside an entry action while
the context is half-written; and
_await_settled_for_snapshotspuntime.sleepon the event-loop thread, so an async child could not progress, the wait burned its full 0.5 s and then returned the torn blob anyway. Now: a child stepping on another thread (a non-blocking sync actor) is waited for until settled; a child on the caller’s own thread is refused instantly withSnapshotMidStepError(child=True). - Null/absent
machine_hashon a versioned payload is drift (#185). The v0 bypass was keyed on the field’s presence, so aversion: 2blob that lost its hash in transit restored into a drifted machine silently. The bypass is keyed on the declared version;verify_machine_hash=Falseremains the explicit opt-out. - A
configurationthat contradictsstate_idsis corrupt (#186).configuration or state_idslet an emptied or rewrittenconfigurationsilently win or silently lose; the two must agree or the blob is refused withSnapshotCorruptError. SyncInterpreterclears its per-step scopes on every step (#188)._deferred_this_stepwas cleared only on thewait=Truepath and grew unbounded under fire-and-forget sends, contaminating the next receipt’sdeferred.- The
onUnhandled: "error"kill is on the sender’s receipt (#189).Receipt.erroris theUnhandledEventError; a success-shaped receipt no longer goes back to the caller whose event stopped the machine. - A
"*"handler does not defeatstrict(#190).is_known_event()answered the dispatch question (“would some handler match?”) so one wildcard anywhere silently disabled event-name enforcement for the whole chart. It now answers the declaration question; the validator’sraisecheck asks the dispatch question explicitly (wildcard_matches=True). Dispatch is unchanged. - #175 case D pinned as filed: N sends of one reused
Eventinstance followed bystop()before the loop runs resolve every receipt asInterpreterStoppedError; across the racing window a receipt isokiff its event was applied and every event still queued atstop()is stopped. - #174 re-measured on the fixed tree:
after: 50under a 100-event busy loop on the same machine, and under 100 busy machines sharing the loop, is 0–1 ms late (median of 5). The plain-def-service case is unchanged and documented;async def— now budget-safe — is the documented remedy and fires on time (122 ms forafter: 100beside a 500 ms service).
- One charged lane for every completion (#179; reopened #167/#168).
An
- 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
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.9.0 note: plain external events were still counted and could be discarded — see #77 above; the two engines agree as of 0.9.0.)- 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