Ref 007 · writing ·
Small state machines
Most automation bugs are undeclared states. Writing the states down costs an afternoon and is embarrassingly effective.
Anything that runs on a schedule and touches the real world is a state machine, whether or not you wrote it as one. Certificate renewal, user offboarding, invoice sync, backup rotation — each of them is “a thing that moves through phases and occasionally fails between two of them.” The bugs that hurt are almost never in the phases. They’re in the states you didn’t admit existed.
Offboarding is the cleanest example. The naive script is a straight line: disable the account, revoke sessions, reassign licenses, archive the mailbox, notify the manager. It works in the demo. Then one Tuesday the license API times out after sessions were revoked, the script exits, and you now have a user in a state your automation has no name for — half-offboarded, invisible to the rerun logic, and discovered three weeks later by an auditor. The failure isn’t the timeout. The failure is that “sessions revoked, licenses unknown” wasn’t a state, so nothing could see it, resume it, or count it.
Write the states down first
The fix costs an afternoon. Before touching the script, write the states as data — in TypeScript, a union type is enough:
type OffboardingState =
| { step: 'requested'; requestedBy: string }
| { step: 'account-disabled' }
| { step: 'sessions-revoked' }
| { step: 'licenses-reassigned' }
| { step: 'mailbox-archived' }
| { step: 'done'; completedAt: string }
| { step: 'stuck'; after: string; error: string };
Two things in that list do the real work. Every transition is now a function
from one named state to the next, which means every failure lands in a
named state instead of between two of them. And stuck is a first-class
state, not an exception trace — so “show me everything currently stuck” is a
query, and your monitoring is a select instead of a log search.
Rules that keep it small
- Store the state where you can read it without tooling. A JSON file per case, a table, a work-item label. If finding out the current state requires reading logs, the state isn’t stored, it’s implied.
- Transitions are idempotent or they aren’t transitions. Re-running “disable account” on a disabled account must succeed silently. This is what makes “resume from current state” trivial and 2 a.m. interventions boring.
- Failure states are states.
stuckgets a reason and a timestamp. If a case can be stuck for two different reasons that need different humans, that’s two states. Resist the enum callederror. - The machine doesn’t schedule itself. Cron runs the step function; the state decides what the step function does. Separating “when does it run” from “what happens next” is most of the reason this pattern survives contact with real infrastructure.
None of this needs a workflow engine. A workflow engine is what you buy when
the states belong to someone else’s product; a union type and a folder of
JSON is what you use when they belong to you. Start with the type. You can
tell you’ve got the states right when the incident report writes itself:
“case was in sessions-revoked, transition to licenses-reassigned failed,
case moved to stuck(licenses), alert fired.” Every noun in that sentence
existed before the incident did.