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
Writethat 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-mutantsfails 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.tlaand 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 theWritewaits for credit, callsCloseWritefrom another goroutine, and lets the peer read to EOF. - Numbers
- Before the fix the
Writereturned 263,144 and a nil error; the peer received the window only. After: the window andio.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(thewrite-racemutant must be caught) andgo test -run TestWriteRacingCloseWriteSendsNothingAfterFIN .. - Limits
- Small configuration (two sessions, a few frames); tests,
-raceand 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.tlalet the old replica refuse a forward for an agent that had moved; it now passes it on once to the replica the record names (invariantMovedForwards, mutantpeer-refuses-movedcaught 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-mutantsandmake 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-tracesin 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 cilists 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.