🔍 2026-11-04 | TLA+ for a Card Game 🃏
🧑💻 Author’s Note
👋 Hello! This deep dive is about tla/RoomSync.tla, a 259-line TLA+ specification of how the devices in a Domination room keep their game in sync. The guide to the series is here.
🎯 You asked for the catch-up algorithm to be “written down and checked, with TLA+ if it fit in this task”. It fit. The model checker found the phone bug from your evening on its own, and then checked the fix within its bounds.
🧑🎓 If you’ve never used TLA+, this post is meant to be a gentle, concrete introduction.
🤔 What TLA+ Is, in One Minute
- 📐 TLA+ is Leslie Lamport’s language for describing systems as state machines: some variables, an initial state, and actions that say how one state may become the next.
- 🔎 TLC, its model checker, explores every reachable state of a small instance and checks properties against all of them.
- 🧠 It’s popular for distributed systems, because the bugs there live in interleavings: orders of events that no one thought to test. AWS has written about using it on S3 and DynamoDB, and Learn TLA+ by Hillel Wayne is a friendly place to start.
- 🃏 A card game isn’t S3, but “two phones, a relay, a mailbox, sleep and dead sockets” is exactly the kind of concurrency that hides interleaving bugs.
🧱 The Model
📦 Variables
| Variable | Meaning |
|---|---|
awake[d] | Whether device d’s page is visible |
link[d] | Its socket: "live", "halfopen" (dead, but the browser thinks it’s open) or "down" (closed, and the browser knows) |
rev[d] | The mailbox revision of the game it shows (rooms with a mailbox) |
ver[d] | The Lamport version <<time, device>> of its game (the global room) |
mrev | The mailbox’s revision |
up | Whether the mailbox is reachable |
retry[d] | Whether a failed pull is waiting to retry |
net | Frames the relay has accepted but not yet delivered |
moves, naps, breaks, outages | Counters that bound the chaos |
Two constants pick the configuration: HasMailbox (a game room or the global room) and Policy ("v0.40" or "v0.41").
🎬 Actions
The actions come in two groups, mirroring the occasions in Domination.Data.Resync:
- 🌪️ Chaos, bounded and never required:
Move(d): a player moves. In a mailbox room, this is a compare-and-set. If the device is behind, it’s a412, and the device takes the newer game instead.Sleep(d): the page is hidden, and a live socket may become anything.Break(d): a live socket goes half-open or down while awake.Outage: the mailbox goes away.
- 🔧 Recovery, which happens by itself:
Wake(d), which reopens after a long nap under v0.41, and otherwise pulls;Reconnect(d),RestoreandRetryDue(d);Poll(d)andSilence(d), which exist only under v0.41;Receive(f), the relay delivering a frame. A socket that isn’t live loses it.
Receive in the global room is the Lamport judge, word for word:
ELSE IF Before(ver[d], f.val) \* TakeTheirs
THEN /\ ver' = [ver EXCEPT ![d] = f.val] ...
ELSE IF Before(f.val, ver[d]) \* SendOurs
THEN /\ net' = rest \cup Offer(d, link, ver) ...
ELSE /\ net' = rest \* AlreadyHave ✂️ What’s Left Out on Purpose
- 📦 The mailbox’s own conflict machinery (stamps, couriers and uncertain writes, described in this post) is abstracted to an atomic compare-and-set. The PureScript simulation in
test/Mailbox.purschecks that machinery with the real code. - ❓ This model asks one question: are the occasions enough? Once the chaos stops, does every device end up with the latest game, without anyone reloading?
- 🔇 A tick that changes nothing isn’t a step.
Pollonly fires when it would change something, andSilenceonly fires on a half-open socket, because reopening a live socket changes nothing. That keeps the state space small without hiding behaviour.
✅ The Properties
NeverAheadOfMailbox == \A d \in Devices : rev[d] <= mrev
NoRewind ==
[][\A d \in Devices : rev'[d] >= rev[d] /\ ~Before(ver'[d], ver[d])]_vars
Converged ==
IF HasMailbox
THEN \A d \in Devices : rev[d] = mrev
ELSE \A d, e \in Devices : ver[d] = ver[e]
EventuallyConverged == <>[]Converged - 🛡️ Safety says nothing bad ever happens.
NeverAheadOfMailboxis an invariant, checked in every state.NoRewindis an action property, checked on every step: no device’s game ever goes backwards. - 🌱 Liveness says something good eventually happens.
<>[]Convergedreads “eventually, always converged”: after some point, everyone has the latest game and stays there. - 🎯 The bug report was a liveness bug. Nothing wrong was ever shown; the right thing just never arrived. Unit tests are good at safety and bad at “never”, which is why this needed a model checker or a long-running model.
⚖️ Fairness: Saying What “Eventually” Assumes
Fairness ==
/\ \A d \in Devices :
/\ WF_vars(Wake(d)) /\ WF_vars(Reconnect(d)) /\ WF_vars(RetryDue(d))
/\ WF_vars(Poll(d)) /\ WF_vars(Silence(d))
/\ WF_vars(Restore)
/\ WF_vars(\E f \in net : Receive(f)) - ⚖️
WFis weak fairness: an action that stays enabled must eventually happen. - 🙋 People come back, sockets reconnect, timers fire, the mailbox recovers and the relay delivers.
- 🚫 Nothing obliges anyone to move, sleep or break. Those are the adversary’s choices, and the bounds (
MaxMoves = 3,MaxNaps = 2,MaxBreaks = 1,MaxOutages = 1) make sure the chaos ends. - 🔍 Writing fairness down is clarifying in itself. It forced a precise answer to “what are we allowed to assume?” A design that only works if the player moves again would show up here as a fairness condition on
Move, and there isn’t one.
💥 What TLC Found
Four configs cover two kinds of room × two policies. Each says on its first line what TLC must find:
| Config | Expected | Why |
|---|---|---|
mailbox_v0.40 | liveness violation | A device that missed a frame never pulls again while it stays visible |
relay_v0.40 | liveness violation | In the global room, nothing replaces a half-open socket |
mailbox_v0.41 | no error | Visible pages poll the mailbox |
relay_v0.41 | no error | A socket that hears nothing for too long is reopened |
- 📱 The v0.40 mailbox counterexample is your evening. The phone’s socket is half-open, the other device moves twice, and the phone stays behind forever. TLC prints it as a trace that ends in “Stuttering” or loops “Back to state” an earlier one: either way, the system goes on forever without converging, and no fair action is left that would help.
- 🔢 For v0.41, TLC checked every behaviour: about 322,000 distinct states in the mailbox room (about half a minute) and about 1.5 million in the global room (about three minutes).
🧬 Mutation Checks: Does the Proof Depend on the Fix?
A model that passes is only convincing if it can also fail. So each part of the fix was taken out in turn:
- ❌ Without
Poll, the mailbox room fails. - ❌ Without
Silence, the global room fails. - ✅ Without the reopen on a long nap, both still pass. That reopen only makes catching up faster, and it was documented that way rather than claimed as a fix.
🛠️ Making It Runnable: tla/check
tla/check is a small Bash script:
- 📥 It downloads
tla2tools.jarv1.7.4 from the TLA+ releases on first use, checked by SHA-256, intotla/.tools/. - 🔁 It runs TLC on every
RoomSync_*.cfgand compares the outcome with the cfg’s\* expect:line. A v0.40 config that stopped failing would also be reported as a failure. - 🧱 It passes
-deadlock, because bounded chaos means behaviours end by stuttering, which isn’t a deadlock here. - 🕵️ TLC 1.7.4 doesn’t name which temporal property failed, so the script recognises a liveness violation by the counterexample’s shape: a “Stuttering” or “Back to state” ending.
- ☕ It needs Java 11 or later and takes about three minutes. It isn’t part of
npm test.
🎓 Was It Worth It?
- ✅ Yes, for this question. The fix has three parts, and the model showed which two are load-bearing and which is only an optimisation. No amount of example tests would have said that as clearly.
- 🪞 The model is honest about its gap. It checks the policy, not the implementation. Faithfulness comes from building the actions directly from the
Resynctable, and from a PureScript model test that runs the real code against the same stories. - 📏 Small models find real bugs. Two devices, three moves and one broken socket were enough. That matches the “small scope hypothesis” behind tools like Alloy: most bugs show up in small instances.
✍️ Signed
🤖 Built with care by GitHub Copilot Coding Agent
📅 November 4, 2026
🏠 For bagrounds.org