SYSTEMSENGINEERING ESSAY · 7 MIN READ

State machines need invalid transitions

Notebook dates are an editorial chronology, separate from publication dates.

The arrows a diagram leaves out are part of the interface. I would decide what happens when an event arrives in the wrong state before treating the diagram as an implementation specification.

A happy-path diagram is an incomplete contract

A job diagram might show queued becoming running and running becoming succeeded. The implementation still has to answer what happens when success arrives before start, when start arrives twice or when a terminal job receives another event. Ignoring these cases does not remove them from the system. It delegates their semantics to incidental control flow, database defaults or exception handling. I would enumerate the event-state combinations and explicitly classify each as a valid transition, an idempotent repeat, a rejected command or evidence requiring reconciliation.

Those categories are meaningfully different. Rejecting a command tells the caller that the requested operation is not permitted now. A repeated event can be acknowledged without changing state if its identity proves it is the same operation. An unexpected success notification might indicate a delayed external result that deserves investigation rather than silent deletion. The state machine should preserve enough context to distinguish these cases. A single default branch that returns the current state can make every invalid transition look harmless while hiding a broken upstream protocol.

A transition function makes the missing cases visible

Consider an illustrative job with five states and four event kinds. The contract permits start from queued, cancel from queued, and success or failure from running. There are twenty state-event pairs, of which four are accepted and sixteen are rejected. That count is useful for constructing a finite test matrix, although it says nothing by itself about payloads, concurrency or event histories. I would begin with this small total classification before adding richer guards and effects that make the state space harder to inspect.

The runnable example returns a new state or raises an explicit transition error. It does not mutate storage or dispatch work. That purity makes it easy to reason about the legal relation independently of persistence. The example intentionally excludes cancellation of running work, retries and duplicate-event identities; those would require additional protocol decisions rather than a convenient catch-all. A real system can support them, but the implementation should add their states and rules deliberately instead of inferring them from the names of existing terminal states.

Runnable pure transition example; persistence, event identity and side effects are deliberately outside this function. python
from enum import Enum, auto

class State(Enum):
    QUEUED = auto()
    RUNNING = auto()
    SUCCEEDED = auto()
    FAILED = auto()
    CANCELLED = auto()

TRANSITIONS = {
    (State.QUEUED, 'start'): State.RUNNING,
    (State.QUEUED, 'cancel'): State.CANCELLED,
    (State.RUNNING, 'success'): State.SUCCEEDED,
    (State.RUNNING, 'failure'): State.FAILED,
}

def transition(state, event):
    if (state, event) not in TRANSITIONS:
        raise ValueError('invalid transition')
    return TRANSITIONS[state, event]

assert transition(State.QUEUED, 'start') is State.RUNNING
try:
    transition(State.CANCELLED, 'success')
except ValueError:
    pass
else:
    raise AssertionError('terminal transition was accepted')

State names are not enough to enforce a guard

A valid arrow can still require a guard. Starting a queued job might require available capacity, a valid lease and a caller with the right authority. The state name queued does not establish any of those facts. SCXML provides a precise vocabulary for events and guarded transitions, but the application must supply the domain predicates. I would keep the guard's evidence close to the transition and state whether it must be evaluated atomically with the update. A stale capacity check can invalidate an otherwise correct diagram.

Guard failures also need useful outcomes. Resource unavailable may be retryable; unauthorized should not become an automatic retry loop; an expired lease may require a new owner. Combining them into invalid state makes recovery harder and can conceal security or ownership mistakes. The transition result should explain which condition failed without exposing inappropriate internal information. The key is to preserve the distinction between a forbidden edge and an allowed edge whose current preconditions are unmet. Those cases lead callers toward different actions.

References: [1] W3C: State Chart XML

Concurrent transitions need an atomic authority

Two workers can both read queued and independently decide that start is permitted. A pure transition function approves each local view, but the system still needs to ensure only one transition takes effect. A transactional update conditioned on the expected state or revision can establish that boundary. The losing worker must reload or classify the conflict rather than overwrite the winner. I would identify the authority that serializes conflicting transitions, because a correct state relation does not by itself provide concurrency control.

Revision checks also help distinguish a stale command from a command that is invalid under current state. Suppose a caller intends to modify revision seven but the job has advanced to nine. Returning the current state without indicating the conflict can make the caller believe its operation succeeded. A compare-and-set style interface exposes the mismatch. The exact response policy depends on the application, but the persisted transition and its associated event identity should agree atomically where the system claims a single state change.

Effects and state changes have different failure boundaries

A transition from queued to running may need to dispatch work to another system. Updating the local state and sending a remote message are two effects unless a shared transactional mechanism covers them. A crash between them can leave running work that was never dispatched or a dispatched job whose local state remains queued. I would make that gap explicit in the state machine, perhaps through a durable command or outbox record. The diagram should reflect what can be recovered, not merely the ideal order of function calls.

A useful intermediate state can mean dispatch requested rather than pretending the external worker is already executing. A later acknowledgement establishes the next fact. This adds states but removes ambiguity about what the system knows. The counterargument is that too many states create complexity. That is true when they merely rename internal steps; it is less convincing when they represent distinct externally observable failure boundaries. I would add a state when it changes recovery or permitted actions, and avoid states that serve no such purpose.

Recovery is another source of events

Restarts, lease expiration and delayed responses should be modelled as part of the protocol rather than administrative exceptions. A job recorded as running when its worker disappears needs a policy: retry under a new attempt identity, mark uncertain, reconcile with an external authority or fail. The state machine cannot infer whether an effect happened merely because a timer expired. I would separate job identity from attempt identity so a late result from an earlier attempt cannot accidentally complete a newer execution without validation.

An invariant provides a stronger review target than a collection of arrows. Examples include terminal results never being overwritten, each accepted attempt having one owner, or cancellation before dispatch preventing new dispatch. Such statements can be checked across generated event histories or a bounded formal model. The model's assumptions still matter, especially around external effects. I would write the invariant in ordinary language first, then verify that the chosen state representation contains enough information to enforce it through crash, retry and reordering.

References: [2] Leslie Lamport: Specifying Systems

Invalid transitions should leave evidence

An unexpected transition can indicate a caller bug, a delayed event or an implementation race. Record enough bounded context to distinguish them: entity identity, expected revision, current state, event identity and rejection reason. Avoid treating every rejected command as an incident, since some interfaces intentionally allow optimistic attempts. The monitoring policy should focus on patterns that violate expectations. A sudden rise in impossible terminal updates is different from ordinary contention on a busy queue, even though both produce rejected transitions.

The finished specification should make every event-state combination understandable and every persistent transition atomic within its stated boundary. The code can remain small when those decisions are clear. State machines are useful because they expose what is allowed, but their greatest practical value often comes from exposing what is not. I would judge the design by how it handles a duplicate, stale or contradictory event, since those are the cases where a happy-path diagram stops being a picture and becomes a protocol.

When the policy changes, version the transition semantics or migrate persisted states deliberately. An old job can outlive the deployment that created it. Reinterpreting its state under new rules without a migration plan can introduce invalid histories even when every individual version of the transition function looks internally consistent.

Sources and further reading

  1. W3C: State Chart XML

    Specifies state-machine execution, events, guards and transition behaviour. The smaller job protocol below deliberately defines its own rejection policy.

  2. Leslie Lamport: Specifying Systems

    Provides a formal approach to states, actions and invariants. The practical review method and examples are original analysis rather than a formal proof.

FROM THE NOTEBOOK.

Back to all notes