What TLA+ found in nfltr: designs checked before code, code checked against them

nfltr is a relay that machines dial out to, a stream multiplexer under it (link), and a hub that runs Claude Code agents on other machines (distributed Claude Code). The bugs that hurt there are silent: a write that reports success while its bytes never arrive, a session that never ends, a revoked key that keeps working, a result delivered twice or not at all. They pass unit tests and -race, and show up later as a truncated download or a test that fails once in fifty runs.

After the process post, a reader asked how we had found our relay bugs. Honestly: mostly after shipping, from soaks, flaky tests and other people's test suites. Since 2026-11-01 we have worked the other way round. Every protocol, concurrency, storage or cross-process state machine is first written as a TLA+ spec and checked with TLC, the TLA+ model checker, before its code is written; then the Go code records what it does, and those recordings are checked against the spec. This post reports what that found in five days, what it missed, and what it costs.

What TLA+ found

  • Silent bugs in shipped code, among them: a Write that returned success for 263,144 bytes the peer never read; a revoked key accepted again for 30 s; a revoked share code handed out again; a hub turn run twice under one id; a result released before it was saved.
  • Designs refuted before code: proposed fixes that the checker showed wrong in a few states, so the wrong version was never written.
  • Its misses: several bugs were first seen as flaky tests or end-to-end failures; the specs then had to be corrected, because they had modelled the code too coarsely.
  • The cost: on our Linux CI runner, the spec steps take 18 to 42 % of each push's CI time in the three repositories that report it per step.

Contents: the method · an example · what it found · how it made nfltr more reliable · traces · what it missed · cost · rerun it · fit

The method

One rule, applied in all five repositories (link, the relay SDK, the relay core, the relay server and nfltr itself): a change to a state machine whose bugs would be silent starts as a spec. The spec is the design section of the change; code starts only after TLC passes. Each spec lives in its repository's spec/ directory with a README that maps every spec action to the Go functions it models.

  • Invariants and liveness. What must always hold (no data after a FIN, a result delivered once) and what must eventually happen (a session ends once both sides end it). TLC explores every reachable state of a small configuration: two sides, a few sessions, one crash.
  • Coverage. TLC runs with -coverage 1; an action the model never takes is a bug in the spec, not a pass.
  • Witnesses. For each invariant, a property TLC must violate, to prove the situation it guards is reached. Without one, an invariant can hold because nothing interesting ever happens. This caught one of ours: link's crash configuration had never actually crashed.
  • Mutants. Every bug we find, and every failure a design prevents, is kept as a mutant: the spec with the old behaviour switched back on. make spec-mutants fails unless TLC reports a violation for every one. A spec that cannot tell the old code from the fixed one proves nothing about the fix.
  • Trace validation. A build tag makes the Go code write one NDJSON line per spec action at the moment the state changes; without the tag the recorder compiles to nothing. The repository's existing tests run with the tag, and TLC checks that each recorded run is a behaviour of the spec. Traces from several processes carry vector clocks and are merged by causal order, never by wall clock. A trace the spec rejects means the spec or the code is wrong, and someone decides which.
  • Model-based replays. The reverse direction: TLC's random walks through the spec are replayed against the real code, comparing state after every step.
  • Drift check. Each spec README lists the files it models. A commit that changes one of them without touching the spec, or without a Spec-Reviewed: line saying why the spec still holds, fails CI.

Specs are layered like the code. link's spec states what a layer above may assume of a session (ordered, credited, a clean end only once the peer read the FIN). The relay core models link only by that interface, and a refinement check ties the interface to link's full spec. The hub's spec states what it assumes of the relay in the same way.

An example: a write that raced its own FIN

The first spec was link's, as a pilot of about 90 minutes. link carries many sessions over one connection; a session can be half-closed (CloseWrite) while another goroutine is still in Write. The model separates the two steps the code takes: Write checks the session under a lock, may wait for credit or its turn to write, and only then buffers its frame. The fix is the check on both sides of the wait:

\* A side may send data only while its write side is open and it holds credit.
DataOK(p, s) == ~ss[p][s].wfin /\ cr[p][s].on /\ Answered(p, s)

SendCheck(p, s) ==                     \* Conn.sendData, before it may wait
    /\ pend[p][s] > 0 /\ wt[p][s] = "no"
    /\ Mutant = "write-race" \/ DataOK(p, s)
    /\ wt' = [wt EXCEPT ![p][s] = "data"]
    /\ UNCHANGED <<...>>

SendBuffer(p, s) ==                    \* after the wait: check again
    /\ wt[p][s] = "data"
    /\ Mutant \in {"write-race", "turns-no-recheck"} \/ DataOK(p, s)
    /\ Buffer(p, s)
    /\ wt' = [wt EXCEPT ![p][s] = "no"]
    /\ UNCHANGED <<...>>

With the mutant write-race (the shipped code: no check), TLC found a data frame sent after the session's FIN, a few states deep. The peer had already queued a clean end and dropped the data; the writer was told everything was sent. A later review found the second half: with only the first check (turns-no-recheck), a write parked behind another goroutine's turn still sent after the FIN.

Evidence: link's write racing its own half close

Setup
link's Link.tla and Go tests, 2026-11-01 and 2026-11-02, a laptop at load 7 to 16. The Go test writes a window plus 1,000 bytes so the Write waits for credit, calls CloseWrite from another goroutine, and lets the peer read to EOF.
Numbers
Before the fix the Write returned 263,144 and a nil error; the peer received the window only. After: the window and io.ErrClosedPipe. 4 of 4 mutants caught at depth 7 to 9 in the pilot; 13 of 13 traces from the existing tests accepted, then 14 of 14 after the fixes.
Reproduce
In github.com/nfltr/link: make spec-mutants (the write-race mutant must be caught) and go test -run TestWriteRacingCloseWriteSendsNothingAfterFIN ..
Limits
Small configuration (two sessions, a few frames); tests, -race and soaks had all passed on the old code, which is the point and also why we cannot say how often it bit in practice.

What it found

Each repository's spec/README.md lists its findings, with severity, the test that failed before the fix and the mutant that keeps it fixed. The READMEs count differently (some number review findings separately), so the totals below are approximate and from our reading: about 40 findings in the relay core, about 55 shipped-code fixes in the relay server, and dozens in link, the relay SDK and the hub. Most were low severity. These are the silent ones:

spec the silent failure where found by status
link sessions Write reported success for data sent after the FIN shipped TLC fixed
link joints a clean end reached the client while its tail was still in a dead connection shipped a review of the spec fixed
link liveness a process stalled longer than its timeout took a live peer for dead (5.1 to 7.3 s stalls) shipped a measured stall, then the model fixed
link credit waiters several senders waiting on one session stranded each other: 427 of 500 frames, then nothing shipped a flaky test, then the model fixed
relay SDK call signaling frames sent before the direct path joined were lost if the relay path broke shipped TLC, 4 states fixed
relay SDK calls a late request frame started a second handler call shipped comparing code to the spec fixed
relay core agent link an aborted session reached the client as a clean end (silent truncation) shipped a CI flake (21 of 200 runs at GOMAXPROCS=2), then review fixed
relay core presence a label update wrote an agent's record unfenced: other replicas listed a connected agent as gone shipped TLC fixed
relay core moves a forward reaching the replica an agent just left was answered 503 shipped an end-to-end test, then the model fixed
relay core + server revocation a revocation landing between authentication and hold left the connection uncut and the key cached for 30 s shipped TLC, 4 to 5 states fixed
relay server revocation journal a replica starting while an insert was still open never applied that revocation shipped independent review and a mutant: the trace model had hidden it fixed
relay server shares a visit racing a revoke handed the revoked share code out again shipped TLC, 13 states fixed
relay server mailboxes a re-created mailbox reused sequence numbers, so a reader's cursor skipped and acknowledged records it never saw shipped writing the spec fixed, design checked first
relay server blobs a refresh landing between a sweep's list and its delete released the fresh hold, and the held blob was deleted shipped writing the spec fixed
hub delivery a dispatch started though its record was not saved: after a kill, the turn ran twice under one id shipped TLC fixed
hub delivery plain wait_for_agents is at most once across a hub kill shipped TLC, 5 states documented; acknowledged delivery is exactly once
orchestrator a worker's final result was released before the store write succeeded shipped TLC fixed
orchestrator a start timeout placed a turn again without cancelling it: two runs shipped independent review, then the model fixed
device pairing pairing racing a revoke wrote the revoked device back as trusted shipped writing the spec fixed
worker admission an undrain during a fleet stop released the stop's fence, so the stop killed a worker that had just taken a task shipped writing the spec fixed
releases a killed rollback, re-run, reported "rolled back" while the new version kept serving shipped TLC fixed, with a journal

Some of these were caught in design, before the code they would have broken existed:

  • Per-call credit for agent-to-agent calls. Calls on one agent connection shared a receive loop, so one stalled caller delayed its siblings by up to 10 s. The spec's invariant NoHOL (no call waits on another) came first, for one direction, then both directions on the four shared connection queues, then several senders on one call; the code followed. The flaky test that had shown the stall reproduced it once in about 300 to 3,000 runs; the design removed the cause instead of widening its deadline.
  • The orchestrator's task protocol. We wrote the protocol as it was (Orch.tla), let TLC find a turn run twice under one id in 10 states, wrote the design (OrchDesign.tla) and checked it, then deleted the code path that redispatched under the same id.
  • Fixes that the checker refuted. The first proposed fix for a share-link expiry race, and a proposed "floor" rule for a bounded list of recent problems, were both shown wrong by TLC before any code was written.

How it made nfltr more reliable

Finding bugs is the visible part. The larger effect is that the guarantees below are now stated once, checked on every push, and tied to the code by traces, so a later change that breaks one fails CI instead of production.

Guarantees now checked before code. Each is an invariant of a spec that TLC checks on every push, with a mutant of the old behaviour that it must catch:

guarantee spec checked as
A session's bytes arrive in order, within the receiver's window, and no data follows a FIN link Link.tla Conservation, NoProtocolError; credit and FIN mutants
A clean end reaches an application only once the peer read the FIN link LinkSession.tla, relay core AgentLink.tla refinement check; TypedEnd
One stuck call or session holds only itself relay SDK CallCredit.tla, link NoHOL
A visit is not lost while its agent moves between replicas in a rolling drain relay core Move.tla NoLoss, MovedForwards
A revocation ends the key's connections on every replica relay server Revocation.tla, Journal.tla no held connection of a revoked key; liveness
A finished agent's result reaches the hub once, and only after it is saved hub Hub.tla, orchestrator OrchDesign.tla OutcomeOnce, saved-before-release; 10 delivery mutants

Layers that rely on the layer below. Because link's interface spec states what a session guarantees, the relay core's spec models link in a few lines instead of copying its state machine, and its states stay small enough for CI. When the relay core found that a session could end cleanly while its tail was still in a dead connection, the fix went into link's interface and its code, so every user of link has it, and a refinement check keeps link's full spec within that interface.

Code and spec kept in step. Traces from the ordinary test suites are checked against the specs nightly, and a quick slice on every push. They have caught code that drifted from a correct spec (link's pool held two connections with a maximum of one, because a drain's reservation was split across two locks; link delivered data that arrived after the reader had refused the session, rejected in 12 of 30 traced runs before the fix and 0 of 30 after) and specs that drifted from correct code (a merged hub and node trace rejected because the spec dropped a message the relay does deliver after a node restart; the spec was fixed).

Design first, then code. For each fix above the order was: the spec reproduces the bug as a mutant, the design is changed in the spec until TLC passes and the mutant is caught, then a Go test that fails on the old code, then the code. The rolling-drain fix is a complete example:

Evidence: no visit lost in a rolling drain

Setup
The relay server's two-replica end-to-end test, one laptop: one replica drained under steady site visits while its agents move to the other; 2026-11-06. The spec gap: Move.tla let the old replica refuse a forward for an agent that had moved; it now passes it on once to the replica the record names (invariant MovedForwards, mutant peer-refuses-moved caught 8 states deep).
Numbers
Before: 1 failed visit in about 11 runs. After: 20 of 20 runs, 123,519 visits, 0 failed. Move.cfg: 271,505 states (51,545 distinct), depth 25, every action taken; 66 recorded moves accepted by the trace check.
Reproduce
The spec and its traces are public: in github.com/nfltr/relay-core, make spec spec-mutants and make spec-traces. The end-to-end test is in the closed relay server.
Limits
Two replicas on one machine; the measurement used the relay core fix before it was released. A TCP session forwarded the same way is not in the model yet and was not exercised by this test.

What reliability looks like from outside. These runs check the same properties with real processes and real faults. They are not caused by TLA+ alone; the network-fault runs predate the specs:

Evidence: faults, deliveries and the hourly canary

Setup
Nightly network-fault runs on our Linux CI runner (Docker, iptables and tc: partitions, loss, latency, NAT rebinding, relay restarts) against request/response, stream and HTTP traffic with sequence numbers and checksums at both ends; the hourly production canary (a fresh hub runs an agent on each of three canary machines, one pushes a branch, and checks delivery and cleanup); the hub's traced tests.
Numbers
Network faults: 11 of 11 nightly runs passed from 2026-10-27 to 2026-11-06, 0 data errors in the latest run's 8 scenarios. Canary: 92 of 92 hourly runs since 2026-11-02; 753 of 763 since 2026-10-01 (98.7 %), the 10 failures a hub that did not start (5), a canary machine missing (4), and one monitor. Hub: 232 of 234 traces from its unit tests accepted (the other two: a test that hand-edits the hub's state file, and one too large to check), 2 of 2 merged hub and node traces accepted, 4 of 4 corrupted ones rejected.
Reproduce
Fault injection as in the process post; trace checks with make spec-traces in the public repositories.
Limits
One host's Docker; the canary uses a test double, not a model, and runs once an hour; the canary's better week coincides with the specs but also with other fixes, so it is not evidence of cause.

Trace validation

A spec is our reading of the code; trace validation is how we find out where that reading is wrong. Counts as recorded in the spec READMEs:

repository traces result
link 14 traces of the session tests, 8 to 15,695 lines; pool, liveness and joint traces all accepted after fixes; a refusal race rejected 12 of 30 runs before its fix, 30 of 30 accepted after
relay SDK 72 agent-to-agent call transactions; 8 call directions of credit all accepted; 2 to 5 planted bad traces per spec rejected
relay core 16 agent link, 66 move, 18 relay-side and 18 agent-side handoff traces accepted; 14 planted bad traces rejected
relay server traces from the stores and replica tests, plus about 1,400 model-based walks replayed on the code accepted after fixes; 52 of 181 share traces first rejected, all recorder or test bugs, none in the code
hub 232 of 234 unit-test traces, 3 end-to-end traces, 2 merged multi-process traces the 2 not accepted are explained above

Rejected traces found real bugs (a pool past its maximum, data buffered after a refusal was read) and spec bugs (a spec stricter than the protocol). They also found test bugs: tests that move a clock the recorder cannot see, or rewrite state files between steps.

What it did not catch

  • Bugs first seen as flakes or failed end-to-end runs. An aborted session ending cleanly (a CI flake), a reconnecting agent's open refused (a flake, red 4 times in two days), a fast reconnect losing a resume, a request failing during a rolling deploy (1 failed visit), and the drain example above. In each, the spec held but had modelled two code steps as one, or left out a message. The fix was to split the action, add the mutant, and then fix the code. A spec is only as fine-grained as the code steps it names.
  • A bug the trace model hid. The revocation journal's missed insert was assumed away by the way the trace spec read the journal; an independent review found it.
  • A vacuous configuration. link's crash configuration never crashed until a witness property said so. Witnesses now exist for most specs, but not all.
  • Time. TLC has no wall clock. Timer bounds, latency and starvation are modelled as discrete ticks at best; a goroutine leak was found by a leak check, and timing flakes by starving the machine.
  • Small configurations. Two sides, one or two agents, one crash. A bug that needs three replicas and two crashes is not excluded.
  • Open items. The directory's read-then-write operations in Redis leave windows of one round trip (they need a compare-and-set in the store); some specs have no trace recorder yet; model-based replays exist for few specs. And at the time of writing, one nightly hub check has been red for two nights: a mutant of the hub and node monitor protocol is no longer caught by the larger configuration. We have not looked at it yet.

What it costs

Evidence: CI and nightly seconds

Setup
Our Linux CI runner (a container with 3 CPUs on a laptop), the latest green run of each repository's main branch, 2026-11-05 and 2026-11-06; the nightly jobs of 2026-11-06. TLA+ source: link 2,267 lines, relay SDK 1,118, relay core 2,404, relay server 6,676, nfltr 6,724, trace modules included.
Numbers
Per push: relay core spec and mutants 172 s of a 407 s run; relay server spec, mutants and a quick trace check 183 s of 1,027 s, plus 99 s of drift check (most of it the check's own tests); nfltr's four spec gates 381 s of 911 s (release 257, hub 84, orchestrator 28, admission 12), plus 116 s of drift check. link's per-push spec job takes about 44 s on GitHub's runner. One CI run makes 64 mutant checks in the relay core, 173 in the relay server and 162 in nfltr, none missed. On a loaded laptop at background priority, link's make spec-mutants (43 mutants, 18 bad traces) took 9 min and the relay SDK's 24 mutants and 21 witnesses 7 min. Nightly: link's large crash configuration 69 min; relay core liveness 19 min; relay server liveness 4.5 min and traces 4.7 min; hub 6.3 min.
Reproduce
Each repository's make ci lists its spec steps; the public repositories run the same steps in GitHub Actions.
Limits
Seconds on a shared runner vary with load. People time is not in these numbers: the first specs took 1 to 3 hours each to write and check, and every later change to a modelled file needs its spec reviewed.

Rerun it

link, the relay SDK and the relay core are public. Each needs Go and a JRE; scripts/fetch-tla.sh downloads a pinned tla2tools.jar (link's also a pinned JRE when none is installed) into a cache outside the repository.

git clone https://github.com/nfltr/link && cd link
make spec-invariants   # what CI runs: invariants, the crash config, refinement
make spec-mutants      # every mutant, witness and bad trace must be caught
make spec-traces       # the Go tests with -tags linktrace, each trace checked by TLC

In github.com/nfltr/relay, make spec spec-mutants spec-cltraces; in github.com/nfltr/relay-core, make spec spec-mutants and make spec-traces. Each spec/README.md explains the model, the action-to-function map, the findings and the measurements. The relay server's and the hub's specs are in closed repositories; this post describes them.

Is it for you?

Fits if you write Go code where two goroutines, processes or machines share a state machine and a bug would lose, duplicate or reorder data without an error: a protocol, a lease, a queue with acknowledgements, a cache that must honour a revocation. Start with one spec of the piece you trust least, keep it under a few hundred lines, and add a mutant for the last bug you fixed there. If TLC cannot tell the bug from the fix, the spec is not yet saying anything.

Not ready for a guarantee that the code is correct. A spec checks a small configuration of our reading of the code; trace validation checks only the runs our tests produce. It found many bugs that tests missed and missed several that tests found, so we run both.

Try next: read link's spec/README.md beside Link.tla, then the process post for the tests and fault injection around it, and the link post for what the multiplexer does.

← All posts