What a green run actually says
A test is an existential statement: “there is a run in which the system behaves correctly”. A suite is many such statements. Neither one of them nor all of them together says “no behaviour that breaks the system exists” — they say “we did not run into one”.
For straight-line code the difference barely matters: the paths are countable and coverage closes them. It becomes decisive where the order of steps is not chosen by the programmer: two requests against one account, a payment retried after a timeout, a queue and its consumer, a lock and the wait for it. There the space of behaviours is combinatorial, and tests take a handful out of it.
Making a rare schedule reproducible is a separate job, and it is solved by deterministic simulation: a seed instead of randomness, byte-for-byte replay. But that is still sampling. Two hundred schedules out of an astronomical space is a very good fuzz run, not a theorem.
Where the difference costs money
None of these is about the amount of code; all are about the number of combinations. That is exactly where exhaustive checking applies: protocols, state machines, locking, coordination between services. Ordinary CRUD is not in that set, and dragging model checking there is a waste.
What model checking is
You describe the system as a state machine: initial states, actions (each with a guard and an effect) and the statements that must always hold. The tool then visits every reachable state and either finds a path into a state where a statement is false, or proves that no such path exists.
const spec = {
name: 'counter',
processes: 2,
init: [{ n: 0 }],
actions: [
{
name: 'p0: increment',
process: 0,
reads: ['n'],
writes: ['n'],
step: (s) => (s.n < 3 ? [{ n: s.n + 1 }] : []),
},
],
invariants: [{ name: 'n stays small', reads: ['n'], holds: (s) => s.n <= 3 }],
terminal: (s) => s.n === 3,
};One detail of the shape matters: the guard and the effect live in one function. The action returns its successor states, and an empty list means “impossible right now”. That way the two cannot drift apart — a classic source of wrong hand-written models.
The second: reads, writes and the symmetric process sets are declared, not guessed. TLA+ asks for the same thing. The price is honest: a wrong declaration becomes a bug in the specification rather than silent unsoundness in a tool that quietly dropped the states that mattered.
The counterexample matters more than the verdict
“A violation is possible” is useless without the path to it. Breadth-first search makes the path shortest by construction, so the counterexample reads like an incident report rather than a dump.
✗ peterson (check then set) — invariant "at most one process in the critical section"
trace
(initial) pc=00 flag=00
p0: check the other flag pc=10 flag=00
p1: check the other flag pc=11 flag=00
p0: raise flag and enter pc=31 flag=10
p1: raise flag and enter pc=33 flag=11Both look, both see a lowered flag, both walk in. Four steps, and it is obvious why the flag has to go up before the check. Things like this take a minute with a trace and weeks with “sometimes two of them end up inside”.
Why brute force does not work directly
The number of states grows as a product: six processes in five phases is 15,625 combinations, and that is a toy. So the substance of the tool is not the search but everything done to avoid searching.
The last row matters more than the first two. Where processes constantly read and write each other’s variables there is nothing to reduce — and the tool must honestly deliver nothing. That row is an assertion in the test suite: a tool that claimed a gain there would be claiming something false.
Two ways not to search
Symmetry. When processes are interchangeable, states differing only by permuting them are the same state. Six workers in five phases give 15,625 assignments but only 210 multisets: what matters is how many are where, not which one is which.
Commuting actions. When two actions touch nothing in common, the order between them is not observable: having explored one order, you learn nothing from the other. Formally these are ample sets, and their four conditions each earn their place — drop one and the checker will confidently miss violations.
How to check the thing doing the reducing
Here is the ugliest part of the whole exercise. A reduction that drops states it should have kept produces exactly the same output as one that works: “no violation found”. From inside the reduced search there is no way to tell which one you are holding.
The only workable answer is not to take the reduction at its word. The exhaustive search is kept alongside: slow, complete, and unable to be wrong. Every specification runs through both, and the reduced verdict is required to match the full one. The reduction is trusted not because its author is confident but because it agrees with something that cannot be mistaken.
That guarantee has a boundary worth naming: the comparison is only possible where the exhaustive search still runs — on small models. Precisely where the reduction is not needed. It is inherent to the approach, and it does not invalidate the check, but it sets its ceiling.
The same move elsewhere: a bug museum where each exhibit switches off one rule of the algorithm and has to be caught, and a ladder of isolation levels where each rung must produce what the next one forbids.
Liveness: nothing crashes, it just never gets there
Safety asks whether a bad state is reachable, and breadth-first search answers it. Liveness asks whether something good eventually happens, and it is violated by an infinite run in which it never does. In a finite state space such a run is a lasso: a path into a cycle, then the cycle forever.
The trouble is that most such cycles are absurd: they require a process that could run to simply never be scheduled. Weak fairness rules those out — a continuously enabled process must eventually move. Without that condition every concurrent program “fails” liveness and the checker is useless.
✗ spinlock — can loop forever without process 0 reaching its critical section
The cycle is fair: process 1 keeps moving; process 0 is blocked
then forever
p1: acquire pc=03 lock=1
p1: release pc=00 lock=-1A spinlock is perfectly safe — two processes are never inside at once — and it starves. Peterson passes the same check, and the turn variable exists for no other reason. The difference between “safe” and “safe and makes progress” is worth being able to name: in queues, locks and distributed leases it is the source of hangs that monitoring never reports as errors.
What this method cannot do
- It checks the model, not the code. That is both the main limit and the main risk: a proven model and an implementation that drifts from it is an ordinary situation. Models catch mistakes of design, tests catch mistakes of execution; neither replaces the other.
- Reductions are sound with respect to what you declare. An action that writes an undeclared variable can cost you dropped states. It is a contract, not an analysis.
- Only invariants and one form of liveness. Statements of the form “always true” and “eventually happens” under weak fairness. No nested temporal operators, no strong fairness, no full LTL.
- Everything is in memory. Thousands or tens of thousands of states, not billions: no disk-backed store, no symbolic representation.
- For serious work there are TLC and SPIN. Decades of engineering behind them, disk-backed state storage, distributed checking, and languages designed for the job.
When it pays off on a commercial project
- 01
Count who touches the shared state
If two or more independent processes work on the same data — a payment and a webhook, a worker and a scheduler, a user and a background reconciliation — there are more combinations than you will write tests.
- 02
State the invariant in one sentence
“A paid order never returns to awaiting payment”, “two transfers cannot take out more than the balance”. If the invariant cannot be stated, there is nothing to check yet — first agree on what must be true.
- 03
Write a model of 30–60 lines, not the whole system
You model the protocol, not the application: order states, provider steps, failures and retries. Everything else stays out — that is not a simplification but the condition under which the search terminates at all.
- 04
Turn the counterexample into a test
The trace is a ready-made regression scenario. The model stays in the repository, the test guards the implementation, and each does what it is good at.
In money terms this pays off where wrong data costs more than downtime: financial systems, stock accounting, integrations with external providers. Neighbouring write-ups: payment idempotency and what happens to your data when the master dies.
Frequently asked questions
Is this the same thing as TLA+?
The same idea and the same algorithms: describe the system as a state machine and visit the reachable states. TLA+ with TLC is an industrial tool with its own language, disk-backed state storage and distributed checking; for real protocol verification, use it. The difference is the barrier to entry: a model in TypeScript lives in the same repository as the code and is read by the whole team rather than by one enthusiast.
Do we now have to describe the system twice — as a model and as code?
The model describes the protocol, not the system: states, transitions, failures. That is tens of lines, not thousands. There is no duplication precisely because the model is deliberately poorer than the implementation — it has no database, no network and no interface, but it does have every combination of steps.
What if there are too many states?
Shrink the model, not the machine: three processes instead of ten, two currencies instead of thirty. Protocol bugs almost always show up on small instances — a well-known practical observation that the whole practice of modelling rests on. If that still does not help, the problem belongs to TLC or SPIN.
Why write your own model checker when mature ones exist?
To know what I am trusting. Search reductions are code that can quietly drop the states that mattered and still return the same green answer; having written them, you understand exactly what you are risking when you use someone else’s tool. The code is open: pnueli, MIT, linked in the sources.
Sources
Every claim here can be checked: below are the primary sources, not a retelling.
- 01Clarke, Grumberg, Peled. Model Checking — Partial-order reduction and the ample-set conditions
- 02TLA+ and TLC — The industrial tool for the same job
- 03SPIN — Model checking of protocols, decades of practice
- 04Amir Pnueli — Turing Award — Temporal logic in program verification: “always” and “eventually”
- 05pnueli — The tool from this article: MIT, exhaustive search kept beside the reductions

This piece is signed by name, unlike the rest: there is no client behind it. The code it discusses is fully open and published under the same name — you can read it, run it and check the article’s claims instead of taking any of them on trust.