Statecharts · Lesson 05

Context, Guards, and Run-to-Completion

What does not belong in a box, how the chart asks questions about it, and the exact order in which an engine does everything one event sets off — which is where the subtle bugs live.


Not everything is a box

An elevator with a queue of pending floors has no finite state space. Ten floors, any subset pending, in any order — that is millions of configurations and none of them is a useful thing to draw. So the queue is not states. It is a list, hanging off the machine, and the chart only ever asks it yes/no questions.

Extended state (usually just context) — the data a statechart carries alongside its configuration. The configuration decides which transitions exist; the context decides, through guards, which of them fire.

The working rule, and it settles most arguments:

State if the system behaves differently in it — different events are accepted, or the same event does something else.
Context if the range is arithmetic or unbounded, or if the value is carried but never branched on. A retry counter, a queue, a user id, a volume.
A guard wherever a context value does decide behaviour. That is the seam: the counter lives in context, the threshold lives in a guard, and the chart stays finite.

The failure mode in both directions is worth naming. Making context into states gives you retry_1, retry_2, retry_3 and a fourth one next sprint. Making states into context gives you the boolean soup from lesson 01 — isLoading, isError, hasData — where the illegal combinations are representable and reachable.

Guards, and the order they are tried

Several transitions can share an event. They are tried in declaration order and the first whose guard passes fires:

tick = (
    cooking.to(idle, cond="last_second")        # tried first
    | cooking.to.itself(internal=True, on="decrement")
)

This is a deliberate priority list, not a switch whose arms happen to be exclusive. Write the specific case above the general one, and treat two simultaneously-true guards on the same event as a bug even though the engine will quietly pick the first — the reader of the diagram has no way to see which.

A transition can also have no event at all. An eventless transition fires the moment its guard becomes true, with nobody sending anything:

class motion(State.Compound):
    idle = State(initial=True)
    moving_up = State()
    idle.to(moving_up, cond="safe_to_move and target_is_above")   # no name, no event

That is the elevator's dispatcher, and no caller has to remember to start the car. Eventless transitions are how a statechart expresses “this is a standing condition” rather than “this is a thing that happens”.

One event at a time, to completion

Everything above depends on the engine's processing model, which is worth knowing exactly because it is where the subtle bugs are.

Microstep — one set of transitions executed: guards, exits (innermost first), transition actions, entries (outermost first).
Macrostep — everything triggered by one external event. It loops: (1) are any eventless transitions enabled? fire them. (2) otherwise, is the internal queue non-empty? pop one and fire. (3) otherwise the configuration is stable and the macrostep ends.
Two queues — the internal queue (send(..., internal=True), SCXML's raise) is drained inside the current macrostep; the external queue (send()) waits until the machine is stable.

This is run-to-completion: an event is fully processed, including every consequence it sets off, before the next one is looked at. It is what makes a statechart's behaviour predictable without locks, and it is why an action that sends an event does not re-enter the machine.

Read step (1) and (2) again, in that order. Eventless transitions are selected before queued internal events are delivered. So an action that raises an event has not yet had its effect when the next eventless guard is evaluated. In the elevator: arriving at a floor raises “open the doors”, but the dispatcher's guard runs first, sees the doors still shut, and sends the car off to the next floor. This is not a library bug; it is the SCXML order, and P3 is built around making you meet it.

The fix is general: if two regions must agree about something at the instant it becomes true, that something has to be visible to both at that instant — a value in context that the guard reads, not an event still sitting in a queue.

When not to reach for this

Three cases, offered as synthesis rather than as anything Harel wrote:

Nothing branches. If every event is legal in every state and does the same thing, there is no machine — there is a record and some functions.
The entities have independent lifetimes. Three uploads running at once are three machines, not one machine with three regions. Regions are for one object's several aspects, not for several objects.
The interesting part is the data. If the hard question is what the values are rather than what is currently allowed, a statechart adds ceremony and answers nothing.

Exercise 1 — state, context, or guard

Exercise 2 — run the macrostep

Exercise 3 — recall

The rest is practice

That is the whole vocabulary. What remains is judgement, and judgement comes from problems: P3 for regions and context under the run-to-completion rule, P4 for living with someone else's flat machine and then refactoring it, and P5 for all of it at once.

← Lesson 04Status mapP3 →

Sources: SCXML Appendix D · python-statemachine: processing model · Harel 1987 §5 · RESOURCES.md