StateGENERALLANGUAGE-SPECIFICCONTESTED

Invalid Transitions

The moves that must not exist are part of the design. A comment saying "do not cancel after delivery" is a hope; a transition table that has no such row is a rule.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

How do I make an illegal lifecycle move impossible rather than merely discouraged, and how do I record why it is illegal?

The requirement

A support agent cancelled a delivered order. The refund went out, the stock reservation was released for goods already in the customer's hands, and inventory has been one unit wrong ever since. The code had a comment: // don't cancel delivered orders.

The obvious build

Guard the specific case that went wrong. Add if (order.state === 'delivered') throw at the top of the cancel handler, and note it in the code review checklist.

Why it breaks

It fixes one cell of the matrix. delivered -> picking, cancelled -> shipped and refunded -> refunded are equally illegal and equally unguarded, and each waits for its own incident.

How it breaks as requirements change
  • It fixes one cell of the matrix. delivered -> picking, cancelled -> shipped and refunded -> refunded are equally illegal and equally unguarded, and each waits for its own incident.
  • The guard lives in the cancel handler, so the next way to cancel — a bulk tool, an API endpoint, a support macro — does not have it (Shotgun Surgery).
  • The reason is lost. Six months later someone reads if (state === 'delivered') throw and cannot tell whether it protects inventory, accounting or a carrier contract, so nobody dares change it and nobody can extend it.
  • Denying the action does not remove the business need. Support still has a customer with a damaged parcel, so they find another way — usually a data fix, which bypasses every guard there is (Invariant Leaks).
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • Support genuinely needs to handle post-delivery problems, so simply blocking the action is not an acceptable answer to the business.
  • The admin tool is used under time pressure with a phone in one hand, so any rule enforced only by training will be broken.
  • Historical data already contains a handful of illegal transitions that cannot be undone.
Invariants
  • A transition not listed as legal cannot occur, by any path that the application controls.
  • Every refusal produces a reason that names the rule, not a generic error.
  • Every forbidden transition has a recorded reason for being forbidden, because the reason is the business knowledge.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • The transition table owns the closed set of legal moves; anything absent is refused by default rather than by an explicit check.
  • The forbidden list owns the reasons — it is documentation that lives in the enforcement mechanism and therefore cannot drift from it (Docs Close to Code).
  • The business owns which moves are legal. An engineer inferring the rule from what the code currently does is guessing.
  • Somebody owns the legitimate need that the forbidden move was covering for, and answering it properly is part of this design rather than a separate ticket.
Boundaries
  • Default-deny is the boundary: the machine refuses anything not in the table, so new states are safe by construction rather than needing new guards (Least Privilege as a Design Decision is the same principle applied to permissions).
  • The line between "forbidden" and "not yet needed" must be explicit, because they look identical in a table and mean opposite things when someone wants to add a row.
  • Data fixes and bulk SQL sit outside the boundary. That is a hole, and the response is a database constraint for the transitions that matter most (Database Constraints).

The forbidden list is the design document

Below is the same order machine with its refusals written out properly. Each why is a sentence of business knowledge that previously existed in nobody's head reliably — the kind of thing a team rediscovers by incident every eighteen months.

Note that two of the refusals point at a legal alternative. That is the difference between a rule and an obstruction: shipped -> cancelled is forbidden *and* the Return flow exists, so support has somewhere to go.

What must not happen, and what it costs when it does
createdpaidpickingshippeddelivered ·cancelled ·returning
FromOnToGuardEffect
createdPaymentCapturedpaidamount equals totalreserve stock
paidWarehouseAcceptedpickingreservation liveprint pick list
pickingCarrierAcceptedshippedall active lines pickedconsume reservation; issue tracking
shippedCarrierConfirmeddeliveredopen returns window
createdCancelcancelledvoid authorization
paidCancelcancelledwarehouse has not acceptedrefund (keyed); release reservation
deliveredStartReturnreturningwithin the returns windowissue a return label; open a case
must be impossible
  • delivered → cancelledThe incident that prompted this lesson. The reservation was consumed at dispatch, so releasing it credits stock that physically left the building — inventory drifts up by one unit per occurrence and is only caught at the next physical count. The legitimate need is a return, which is why delivered -> returning exists.
  • shipped → cancelledSame inventory drift, plus a refund issued while the parcel is still in transit and may yet be delivered — so the customer keeps both the goods and the money. Use the return flow after delivery, or a carrier recall before it.
  • cancelled → paidCancellation released the reservation, so the stock may already be sold to another order. Capturing money against it creates an obligation the warehouse cannot meet, discovered days later at picking.
  • delivered → pickingReprocessing a delivered order re-runs every effect on the path: a second pick list, a second dispatch email, a second consumption of stock. Backwards transitions replay effects, which is why this machine has none.
  • created → shippedDispatch with no capture and no reservation. Nothing errors, nobody is notified, and the loss appears only in reconciliation — the most expensive class of failure because it is silent and delayed.
  • returning → cancelledA return in progress has a label issued and a case open. Cancelling it abandons the case while the parcel is in the network, so the refund decision is made twice by two different processes.

Six refusals, each with a cost attached. When a future team wants to legalise one of these — and they will, because the business changes — the why is what turns that from a frightening unknown into a conversation about whether the cost still applies.

Refusal is not an error

The second most common mistake after leaving transitions unguarded is guarding them with exceptions. A refused transition is a completely normal outcome — support asked whether they could cancel, and the answer is no — and modelling it as an exception makes it impossible to enumerate, hard to display and noisy in the logs.

A refusal that carries a reason and a suggested alternative is the difference between a system that says "no" and one that says "not this way, use Return". The second one is what stops people going around the system.

A closed table, and refusals as values
1type Refusal = {
2 refused: true
3 code: 'no-such-transition' | 'guard-failed'
4 from: OrderState
5 on: Event
6 because: string
7 instead?: Event // the legal path, if there is one
8}
9
10// forbidden transitions are DATA, not absence-of-code
11const FORBIDDEN: Record<string, { because: string; instead?: Event }> = {
12 'delivered/Cancel': {
13 because: 'the reservation was consumed at dispatch; releasing it drifts inventory',
14 instead: 'StartReturn',
15 },
16 'shipped/Cancel': {
17 because: 'the parcel is in the carrier network and may still be delivered',
18 instead: 'StartReturn',
19 },
20}
21
22export function apply(o: Order, e: Event): Transition | Refusal {
23 const rule = RULES.find((r) => r.from === o.state && r.on === e)
24 if (!rule) {
25 const known = FORBIDDEN[`${o.state}/${e}`]
26 return {
27 refused: true, code: 'no-such-transition', from: o.state, on: e,
28 because: known?.because ?? 'this transition is not defined',
29 instead: known?.instead,
30 }
31 }
32 // ... guard, then effects
33}

The default branch is the important one: an unlisted transition is refused whether or not anyone thought to forbid it explicitly. FORBIDDEN then adds the *reason* for the cases that have one — so the table gets safer by default and more informative where the team has learned something. instead is what keeps support inside the system instead of in a SQL client (Error Modeling).

The smell: the override that eats the machine

Every strict lifecycle eventually attracts a proposal for an override, and the proposal always has a real incident behind it. The question is not whether the need is legitimate — it is — but whether an unconstrained state-setting capability is the right answer to it.

It usually is not, because the override is available and the correct path sometimes refuses, and human beings under pressure use the thing that works.

smellForce-state override

looks like An admin endpoint POST /orders/:id/state that writes any state to any order, guarded only by a role check. Usually added after an incident, usually with a comment saying it is for emergencies, usually appearing in the audit log dozens of times a week within a year.

suggests The legal paths do not cover a real operational need, and rather than modelling that need the team added a way around every rule at once. Every forbidden transition is now reachable, so the forbidden list documents intentions rather than behaviour, and the effects that transitions normally trigger are skipped — the state moves and the refund, the reservation and the notification do not.

fix Find the need behind the override and give it a legal path — a return flow, a supervised reversal, a partial cancellation. Then replace the general override with the narrow, audited repair tool above, and put an alert on its use so the second occurrence starts a design conversation instead of becoming a habit.

when this is fine It is genuinely correct as a narrow, audited repair tool with a different name and different semantics: a repairState operation that requires a written justification, records who and why, fires an alert to the owning team, is restricted to a specific list of state pairs known to be recoverable, and explicitly does not run effects — because a repair is fixing data that got out of step, not performing a business transition. That tool is legitimate and most systems eventually need one; what makes it safe is that it is neither general nor quiet (Debuggability by Design).

How to build it

Most important first.

  • Make the table closed: no matching row means refused. This converts every unlisted transition from "unhandled" to "forbidden" at zero cost (State Machines).
  • Record why each significant forbidden transition is forbidden, in the table itself. The sentence "releases a consumed reservation, drifting inventory" is worth more than the guard it accompanies.
  • Distinguish refusal from error. A refused transition is an expected outcome with a reason code, not an exception — support should see "cannot cancel after delivery; use Return instead", not a 500 (An Error Taxonomy That Survives Contact).
  • Model the real need as its own legal path. Post-delivery problems become a Return lifecycle with its own states; the forbidden move stays forbidden and the business need is met (Explicit State).
  • Refuse to add an escape hatch. A forceState admin action makes every forbidden transition reachable and will be used, under pressure, by someone who does not know why the rule exists.
  • Put the handful of highest-cost rules in the database as well, because the paths that bypass the application are exactly the ones used during incidents.

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Before: each newly discovered illegal transition costs an incident, a guard in one handler, and no protection for the other handlers. The cost recurs once per illegal cell, and there are many cells.
  • After: illegal transitions cost nothing, because they are the default. Only the exceptions — the legal moves — need to be written.
  • Adding a state under default-deny is cheap and safe: it starts with no legal transitions and each one is added deliberately. Under guard-by-guard enforcement, a new state is legal to move to from everywhere until someone notices.
  • What stays expensive: changing a rule that support has been working around. The workaround is now load-bearing, and removing it requires giving them the legal path first — which is a product change, not a code change.
What the recommended approach costs
  • Default-deny means legitimate new flows are blocked until someone adds a row, which is friction — and during an incident that friction is felt as the system fighting the operator.
  • Refusing to add an override is a real cost borne by support staff, and the only honest way to pay it is to build the legal path they need.
  • Duplicating rules into the database contradicts the single-home principle everywhere else in this domain, and is justified only because it covers a caller the application cannot see.

What can go wrong

Failure modes
  • An overrideState capability is added for support and becomes the normal path within a year, because it always works and the correct path sometimes refuses.
  • The forbidden move is blocked and the underlying need is not met, so users route around the system — a spreadsheet, a manual refund, a direct SQL update — and the state in the database becomes fiction.
  • A wildcard row is added to simplify the table (from: '*', to: 'cancelled') and silently re-legalises every transition the forbidden list was documenting.
  • The mitigation fails too: a database trigger enforcing transitions rejects a legitimate migration or backfill at 3am, and the fix under pressure is to disable the trigger — which is then never re-enabled (Deliberate Debt).
Dependencies, and their direction
  • Every caller depends on the machine for permission, which is what makes the rule uniform across the API, the admin tool and the bulk importer.
  • The refusal reasons become part of the client contract — the admin UI shows them — so they are a small API surface with the usual compatibility obligations (Enum Evolution: The New Value That Broke Old Clients).
  • The database constraint duplicates a subset of the rules deliberately, creating a dependency between two enforcement points that must be kept in step (Duplicate Knowledge).
Misreads
  • "Just add a check where it went wrong." That protects one path against one transition. The value is in the closed table, where everything unlisted is refused and no future path can miss it.
  • "Forbidden means the business never wants it." Often the business wants something adjacent and the forbidden move was a bad way to get it. If you block without modelling the need, you have moved the problem outside the system (Requirements Before Design).
  • "An override is fine if only admins have it." Admins are exactly the people who use it under pressure, without context, at the moment when the rule matters most. The permission is not the safeguard.
  • "The comment documented the rule." A comment is not enforcement and cannot be tested. Moving it into the table costs nothing and turns it into both (Comments).
Smells this explains
  • shotgun-surgery
  • duplicate-knowledge

Testing it, and how it ages

What to test, and at which boundary
  • Enumerate the full matrix: for every state and every event, assert either a defined transition or a refusal with a named reason. This is the test that makes the forbidden list real (Property-Based Testing).
  • A regression test per historical incident: delivered plus Cancel refuses with use-return-flow, forever.
  • A test that no code writes the state field outside the machine — a lint or architecture test, because this is the hole every incident actually goes through (Internal Module Contracts).
  • For rules also enforced in the database, a test that the constraint and the table agree, so the two copies cannot drift (Contract Tests).
How this design ages
  • Forbidden lists grow as incidents teach the team what it did not know, and each addition is cheap. That growth is the healthiest form of learning a codebase can record.
  • Occasionally a forbidden transition becomes legal — the business changes, and post-dispatch cancellation becomes possible because the carrier offers recall. The recorded reason is what makes that conversation possible instead of frightening.
  • The design ages badly if escape hatches accumulate. A machine with three overrides is not a machine, and the forbidden list becomes decorative.

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • GENERALDefault-deny over a closed set of transitions is a structural property, achievable in any language; only the compiler assistance varies.
  • LANGUAGE-SPECIFICWith sum types and exhaustive matching, an unhandled state-event pair is a compile error and the closed set is enforced by the type checker. With string states and a switch carrying a default, the same omission compiles and falls through, so the closed set must be recovered by an enumerating test — the design is identical, the guarantee is not.
  • CONTESTEDThe strongest opposing view: strict transition enforcement in application code is a liability during incidents, because the operator who needs to correct a broken state is blocked by rules written for the happy path, and the workaround becomes direct database access — which is far more dangerous than a supervised override would have been. Practitioners who run high-volume operations argue for an audited override with mandatory justification instead of a hard block. The counter is that overrides are used routinely rather than exceptionally; the honest resolution is that if an override exists it must be audited, rate-limited and reviewed, which most implementations are not.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • Testing & Reliability Engineering — enumerating the whole state-by-event matrix is a small exhaustive test that replaces dozens of example-based ones, and deciding where exhaustive testing is affordable is a strategy question.