Chapter 2 of 8
Specify states, transitions, safety, and liveness
Chapter 2
About 5 minutes · includes examples, an exercise, and references
Chapter at a glance
- •Use a pure reducer to turn state and factual events into next state and effect commands.
- •Express authority as guarded transitions and review every transition against explicit invariants.
- •Separate safety from liveness and state the environmental assumptions needed for progress.
A loop becomes reason-able when it is expressed as a transition system rather than scattered callbacks. Let S be the set of states, E the set of events, and T a next-state relation. A transition consumes a current state and an accepted event, checks a guard, and produces a new state plus zero or more commands. The command describes an intended effect; a later event reports what actually happened. Keeping the reducer pure prevents an in-memory assignment from masquerading as a completed external action.
State should contain only durable facts needed to choose or verify future transitions: lifecycle phase, identifiers, version, observed resources, approvals, budgets, attempts, deadlines, effect receipts, and terminal reason. Large documents and traces can live in referenced immutable artifacts. Derived values such as “is expired” can be computed from a recorded deadline and an injected clock. If two replicas read the same state and event, the reducer should produce the same next state and commands.
Events are facts stated in past tense: request_admitted, evidence_recorded, approval_granted, command_dispatched, effect_confirmed, deadline_reached, or cancellation_received. Commands are imperatives: fetch_order, create_refund, start_test, or notify_operator. A proposed command is not an event. Recording “refund_created” before the payment service confirms it destroys the distinction needed to recover from timeouts. Conversely, store a dispatch intent before execution when the system must not lose work between commitment and delivery.
Guards encode eligibility. An approval transition may require authenticated identity, a matching artifact digest, an allowed role, and an unexpired deadline. Put guards in trusted reducer or policy code, not in prose. Illegal state-event pairs should be rejected or converted into explicit ignored-event records; silently accepting them makes invariants untestable. A transition table is often more useful than a flowchart because it lists source state, event, guard, target state, command, and invariant impact.
Safety properties say that something bad never happens. Examples: at most one refund receipt exists; a write never precedes authorization; terminal state never returns to active; consumed budget never decreases; an approved artifact digest equals the executed artifact digest. Liveness properties say that something good eventually happens under assumptions: every admitted run eventually becomes terminal if dependency responses or deadlines are eventually delivered. Safety is usually unconditional within the controller. Liveness must name fairness and environment assumptions or it becomes impossible to prove.
A state invariant is a predicate true in the initial state and preserved by every transition. Instead of reviewing the happy path, review each transition against each invariant. If approval is required before execute, transitions into executing must establish a valid approval and every transition out must preserve or consume it correctly. This local proof style scales better than mentally simulating every path.
Harel's statecharts introduced hierarchy, concurrency, and broadcast communication to manage state explosion in reactive systems. Hierarchy is useful when many phases share behavior. An active superstate may contain gathering, planning, and executing substates while a cancellation event is handled once at the superstate boundary. Orthogonal regions can model concurrent concerns such as lifecycle and approval. Use them carefully: hidden cross-region coupling can be harder to understand than an explicit product state.
Terminal states deserve the same precision as active states. Define succeeded, rejected, cancelled, expired, exhausted, compensated, quarantined, and needs_operator where relevant. Attach a structured reason and last verified external state. “Failed” alone cannot tell an operator whether retry is safe. Terminal does not necessarily mean data is deleted; it means the controller will take no further effect without a new external event or a new run.
Loops also need monotonic fields. State version increments; consumed budgets do not decrease; approvals move from absent to granted to consumed or expired; effect status advances from intended to dispatched to confirmed or ambiguous, never backward without an explicit compensation record. Monotonicity limits contradictory histories and supports safe comparison when events arrive out of order. If an old observation arrives after a new one, its source version or timestamp lets the reducer ignore it without erasing newer truth.
Model checking explores transition interleavings against properties. TLA+ describes behavior as sequences of states, an initial predicate, and a next-state relation. A small abstraction can uncover duplicate delivery, cancellation races, stale approvals, and missing terminal paths long before implementation tests enumerate them. The model should omit irrelevant payload detail while preserving lifecycle, authority, and concurrency. A counterexample is a concrete trace, not a theoretical embarrassment; translate it into a regression test and a design correction.
Formalization is valuable even without a proof tool. Writing variables, initial values, actions, invariants, liveness goals, and environment assumptions forces ambiguous English into reviewable claims. The aim is not mathematical decoration. It is to ensure every reachable state has a defined owner, legal next events, bounded waiting behavior, and an explanation an operator can act on.
Key points
- Use a pure reducer to turn state and factual events into next state and effect commands.
- Express authority as guarded transitions and review every transition against explicit invariants.
- Separate safety from liveness and state the environmental assumptions needed for progress.
- Use hierarchy, monotonic fields, and small model-checking abstractions to manage complex interleavings.
A pure transition reducer with invariant checks
Read the expected behavior in the surrounding walkthrough, then copy and run this reference implementation.
from __future__ import annotations
from dataclasses import dataclass, replace
from typing import Literal
Phase = Literal["requested", "prepared", "approved", "dispatched", "delivered", "expired"]
@dataclass(frozen=True)
class Export:
phase: Phase = "requested"
artifact: str | None = None
approval: str | None = None
receipt: str | None = None
version: int = 0
def invariant(x: Export) -> bool:
if x.phase in {"approved", "dispatched", "delivered"}:
if x.artifact is None or x.approval != x.artifact:
return False
if x.phase == "delivered":
return isinstance(x.receipt, str) and bool(x.receipt.strip())
return x.receipt is None
def reduce(x: Export, event: tuple[str, str | None]) -> tuple[Export, list[str]]:
kind, value = event
commands: list[str] = []
if x.phase == "requested" and kind == "prepared":
x = replace(x, phase="prepared", artifact=value, version=x.version + 1)
elif x.phase == "prepared" and kind == "approved" and value == x.artifact:
x = replace(x, phase="approved", approval=value, version=x.version + 1)
commands = ["deliver_export"]
elif x.phase == "approved" and kind == "dispatched":
x = replace(x, phase="dispatched", version=x.version + 1)
elif (
x.phase == "dispatched"
and kind == "confirmed"
and isinstance(value, str)
and bool(value.strip())
):
x = replace(x, phase="delivered", receipt=value, version=x.version + 1)
else:
raise ValueError(f"illegal: {x.phase} + {kind}")
assert invariant(x)
return x, commands
state = Export()
for event in [("prepared", "sha256:a"), ("approved", "sha256:a"), ("dispatched", None), ("confirmed", "r-1")]:
state, commands = reduce(state, event)
assert state.phase == "delivered" and state.receipt == "r-1"
print(state)Worked examples
Toy
Turnstile transition table
A turnstile is locked or unlocked and receives coin or push events.
Coin in locked moves to unlocked; push in unlocked moves to locked and records passage. Duplicate coin in unlocked is accepted without an extra passage. The invariant is passages never exceed accepted pushes from unlocked. The small machine exposes illegal and idempotent transitions.
- States and events are finite.
- Each pair has defined behavior.
- The invariant is checked after every transition.
Application
Export approval lifecycle
A data export moves from requested through prepared, approved, delivered, and expired.
Preparation records an artifact digest. Approval binds the digest and recipient. Delivery is a command; delivered is recorded only from a signed storage receipt. Cancellation is legal before delivery but after an ambiguous dispatch it enters needs_operator rather than claiming cancellation succeeded.
- Artifact and approval digests must match.
- Dispatch and confirmation are separate facts.
- Ambiguous cancellation has a dedicated state.
System
Concurrent cancellation and completion
A durable workflow can receive cancellation while its worker reports an external effect.
The formal model permits either event order. If cancellation wins before dispatch, no command is issued. If effect confirmation wins, the workflow records succeeded and cancellation is too late. If dispatch exists without confirmation, cancellation moves to reconciling. The invariant forbids both succeeded and compensated receipts for the same effect generation.
- Both event orders are modeled.
- An ambiguous middle state is reachable and owned.
- Receipts are scoped by effect generation.
Exercise
Specify and challenge a transition system
Turn a multi-step workflow with at least one approval and external write into a reviewable state-machine specification.
- Define variables, initial state, events, commands, guards, terminal states, and a transition table.
- Write four invariants and two liveness properties with their environment assumptions.
- Enumerate duplicate, delayed, reordered, cancellation, and expiry interleavings.
- Create a reduced model whose counterexamples can be translated into implementation tests.
Success criteria
- No event name claims an effect before evidence exists.
- Every transition preserves all invariants or is rejected.
- Every waiting state has a deadline or an assumed future event.
- At least one non-happy-path interleaving has an explicit recovery state.
Reflect: Which state variable exists only because an external effect can be ambiguous?
References and further reading
- Statecharts: A Visual Formalism for Complex SystemsDavid Harel's original 1987 paper introducing hierarchical statecharts for reactive systems.
- Specifying Systems: The TLA+ Language and Tools for Hardware and Software EngineersLeslie Lamport's authoritative text on safety, liveness, state transitions, and model checking.