Skip to content

Core Concepts

This guide explains the main concepts in PyStator: states, transitions, guards, actions, and how they fit together.

Terminology

We use two terms consistently:

Term Meaning
Entity The domain object whose state you are modeling (e.g. order-123, user-456). Each entity has a current FSM state; that state may be stored in a state store or held in memory.
EntitySession The in-process Python object that represents one entity running through a state machine. You create it with machine.create(context=...); it holds current state and context and exposes send(trigger, **payload) and auto-generated helpers like order.confirm() and order.is_confirmed.

So: one entity (e.g. one order) is represented at runtime by one EntitySession when you run in memory, or by rows/cache entries in a StateStore when you use the Orchestrator. The same StateMachine (the FSM definition) is shared across all entities of that type.

Glossary

Term Meaning
Machine (StateMachine) The FSM definition: states and transitions from config (YAML/dict), plus bound guards and actions. One per “type” (e.g. order management, user signup). Stateless.
Entity One instance of that type with its own current state (e.g. order-123, user-456). State may be in memory (EntitySession) or in a state store (Orchestrator).
EntitySession The primary stateful API: one object per entity. Tracks current state and context; use send() / asend() to process events. Use when you have the entity in memory (e.g. request-scoped or in-memory workflow).
Orchestrator Wraps the full loop: load state from a StateStore by entity ID → process → persist → run actions (and optional delayed transitions). Use when state lives in a DB/cache and you identify entities by ID.
Replica A copy of your service (e.g. API pod or worker). All replicas share the same state store and can process events for any entity.

Running a machine: three ways

Choose based on where entity state lives and how you identify entities:

  1. EntitySession (recommended for in-memory, per-entity) Create one EntitySession per entity (e.g. per order in a request). It holds current state and context; call instance.send(trigger, **payload) or the auto-generated instance.confirm()-style methods. Use instance.snapshot() to persist and EntitySession.from_snapshot(machine, data) to restore. Best when you load the entity into memory and process one or a few events before saving.

  2. Stateless process() Call machine.process(current_state, trigger, context) with the state you already have (e.g. from your DB). Pure computation: no side effects, no persistence. You persist the new state and run actions yourself. Use when you want full control over the sandwich loop or when you are not using a EntitySession.

  3. Orchestrator Call orchestrator.process_event(entity_id, trigger, context) synchronously, or await orchestrator.async_process_event(...) in async code. The orchestrator loads state from a StateStore by entity_id, runs the transition, persists the new state, and runs actions. Use when state is stored by entity ID (DB, Redis) and you want a single “process this event for this entity” API. Supports optional schedulers for delayed transitions (after: "5s").

Summary: use EntitySession as the default when you run a stateful machine for one entity in memory; use Orchestrator when you need persistence by entity ID and optional delayed transitions.

Execution model

PyStator is event-driven. Each time something happens (HTTP request, message, cron, or delayed callback), you send an event and get a transition result.

  • With EntitySession: instance.send(trigger, **payload) (or instance.asend() for async guards). The instance updates its own state; you can call instance.snapshot() to persist.
  • With Orchestrator: orchestrator.process_event(...) or await orchestrator.async_process_event(...). The orchestrator loads state, computes the transition, persists, and runs actions.
  • Stateless: machine.process(current_state, trigger, context). You supply state, you persist and run actions.

No long-lived loop per entity: One request/callback typically does one (or a few) send/process call(s). You scale by adding replicas; there is no “one pod per FSM” or per entity.

  • Schedulers: Delayed transitions (after: "5s" in YAML) require a scheduler (Asyncio, Redis, or Celery). The Orchestrator can schedule them when you use it with a scheduler. See Schedulers and delayed transitions.

States

States are the nodes of your state machine. Each state has a name and a type:

Type Meaning
initial Entry point; machine starts here. Exactly one initial state.
stable Normal operating state; can transition to other states.
terminal End state; no outgoing transitions.
error Error state; used by error policy.
parallel Container for orthogonal regions (see Parallel states).

Optional per state:

  • on_enter / on_exit: Lists of action names to run when entering or leaving the state.
  • timeout: After a duration, automatically transition to another state (e.g. destination: timed_out_ack). The destination must be a valid state name (lowercase identifiers).
  • parent / initial_child: For hierarchical (compound) states.
  • regions: For parallel states (orthogonal regions).
  • metadata.context_keys: Optional list of context variable names for documentation (reference only; the engine does not use it). Use this to document which state variables apply to each state; see FSM config reference.

Transitions

A transition defines: from which state(s), on which trigger event, to which state, and optionally guards and actions.

  • trigger: Event name (e.g. exchange_ack, fill). Your application calls instance.send(trigger, **payload) or process(current_state, trigger, context).
  • source: One state name or a list (e.g. open or [open, partially_filled]). Names use lowercase a–z, digits, and underscores only.
  • dest: Target state name (or history pseudo-state like H(parent)).
  • guards: List of guard names (or inline expr: "..."). All must be true for the transition to fire.
  • actions: List of action names (or { name, params }) to run after the transition (after you persist the new state).
  • region: For parallel states, the region this transition applies to.
  • after: Delay (e.g. 5000 ms or "5s") for delayed transitions; requires a scheduler.
  • internal: If true, no exit/enter actions run (internal/self-transition).
  • auto: If true, transition is eventless and fires when entering the source state when guards pass.

Only one transition is chosen per event: the first matching (source + trigger + guards) wins. Order of transition definitions matters when multiple could match.

Guards

Guards are conditions that gate a transition. They are pure: same context → same result; no side effects.

  • Registered guards: Implemented in Python, registered by name (e.g. via @machine.guard("name") or GuardRegistry), bound to the machine. Sync or async.
  • Inline guards: Defined in YAML as expr: "fill_qty >= order_qty". Context variables are available in the expression. Requires pystator[api].

If any guard returns false (or raises), that transition is not taken; the engine may try another transition or return no transition.

Actions

Actions are side effects: notifications, DB updates, messaging. They run after you have persisted the new state (sandwich pattern: Load → Decide → Commit → Act).

  • Registered in an ActionRegistry (e.g. via @machine.action("name")) and executed via ActionExecutor.execute(result, context) (or async variants).
  • Can be attached to transitions (transition actions) and to states (on_enter, on_exit).
  • Execution order for a transition: exit actions (current state) → transition actions → enter actions (target state). Use ExecutionMode.PHASED to run in these phases; SEQUENTIAL or PARALLEL for other policies.
  • Action parameters: In YAML you can pass params to an action; they appear in context under _action_params.

Actions can be sync or async; use async_execute_parallel or async_execute_phased for async actions.

Context

The context is a dictionary you pass into instance.send(trigger, **payload) (payload is merged into context) or process(current_state, trigger, context). It typically holds:

  • The event payload (e.g. fill_qty, order_qty)
  • Entity data (e.g. order id, user id)
  • Any values guards or actions need

Guards and actions receive this same context. Build it in your application; for nested data, you can use flatten_context_for_guards from pystator.recipes to expose a flat view for inline expressions.

Hierarchical states (compound states)

States can have a parent and an initial_child. The machine resolves to a single active leaf state. A transition whose source is a parent applies when the current state is that parent or any descendant. Exit/enter actions follow statechart semantics (exit from leaf up to LCA, enter from LCA down to target). History: use H(parent) or H*(parent) as dest to re-enter the last active substate. See the main README section "Hierarchical States (Statecharts)".

Parallel states (orthogonal regions)

A parallel state contains multiple regions; each region has its own current state. All regions are active at once. Transitions can be region-scoped (region: region_name) so only that region's state is considered. Use enter_parallel_state, process_parallel, and get_parallel_exit_actions for the full lifecycle. See the main README section "Parallel States (Orthogonal Regions)".

Delayed transitions

Transitions can have an after field (e.g. 5000 or "5s"). They are scheduled by a scheduler adapter (asyncio, Redis, or Celery) and fire when the delay expires. Requires an Orchestrator with a scheduler; the state store must persist entity state so the scheduler can re-apply the transition later. See Schedulers and delayed transitions.

The sandwich pattern

PyStator is designed around:

  1. Load current state (from your DB or from a EntitySession).
  2. Decide by calling machine.process(current_state, trigger, context) or instance.send(trigger, **payload) (pure).
  3. Commit by persisting the new state (and any audit) in your DB, or by the instance updating its own state (and optionally calling instance.snapshot()).
  4. Act by running executor.execute(result, context) (or async equivalent).

Actions should not drive transition logic; they run only after a successful commit so that side effects match persisted state. When using EntitySession, you can run actions yourself after send(); when using the Orchestrator, it does steps 1–4 for you.