TLA+ Traffic Lights and Communication Protocol

Table of Contents

1. TLA+

The 2020 version of this page set out two exercises: a traffic light system with three phases (red, yellow, green), fixed phase durations and three intersections under a central controller; and a communication protocol for a network of nodes with message priority levels, message buffering and error handling.

The code that accompanied them was not TLA+. It used consts, type, sig, act and // comments, closer to a C-flavoured pseudo-Alloy than to anything SANY would parse, and the protocol "invariant" was a hundred lines of imperative list surgery that never stated a property. It has been removed. The exercises are kept and done properly below: each spec parses, and each was run through TLC 2.19 (tla2tools.jar, 08 August 2024) on 2026-10-08.

To reproduce, save the module and its config side by side and run:

java -cp tla2tools.jar pcal.trans -nocfg PriorityProtocol.tla   # PlusCal only
java -cp tla2tools.jar tlc2.TLC -workers auto TrafficLight

1.1. Traffic lights

verified · tlc-2.19 · 2026-10-09

A controller holds the commanded phase and a countdown. Each intersection's light follows the controller asynchronously, as real actuation does. The controller's clock runs regardless; it may only advance to the next phase when every light has caught up. The 2020 sketch had a separate "transition time" between phases; here yellow is that transition, and an all-red clearance would be a fourth phase. Durations are abstract ticks, kept small so TLC can enumerate them.

---------------------------- MODULE TrafficLight ----------------------------
(* A central controller commands a phase; N intersection lights follow it
   asynchronously. The controller's clock runs regardless, but it only
   advances once every light has caught up. Time is in abstract ticks.   *)
EXTENDS Naturals

CONSTANTS N, RedTicks, GreenTicks, YellowTicks

Intersections == 1..N
Phases == {"Red", "Green", "Yellow"}

NextPhase(p) == CASE p = "Red"    -> "Green"
                  [] p = "Green"  -> "Yellow"
                  [] p = "Yellow" -> "Red"

Duration(p) == CASE p = "Red"    -> RedTicks
                 [] p = "Green"  -> GreenTicks
                 [] p = "Yellow" -> YellowTicks

VARIABLES phase, timer, light
vars == <<phase, timer, light>>

TypeOK == /\ phase \in Phases
          /\ timer \in 0..(RedTicks + GreenTicks + YellowTicks)
          /\ light \in [Intersections -> Phases]

AllCaughtUp == \A i \in Intersections : light[i] = phase

Init == /\ phase = "Red"
        /\ timer = Duration("Red")
        /\ light = [i \in Intersections |-> "Red"]

Tick == /\ timer > 0
        /\ timer' = timer - 1
        /\ UNCHANGED <<phase, light>>

Advance == /\ timer = 0
           /\ AllCaughtUp
           /\ phase' = NextPhase(phase)
           /\ timer' = Duration(NextPhase(phase))
           /\ UNCHANGED light

Follow(i) == /\ light[i] # phase
             /\ light' = [light EXCEPT ![i] = phase]
             /\ UNCHANGED <<phase, timer>>

Next == Tick \/ Advance \/ \E i \in Intersections : Follow(i)

Spec == Init /\ [][Next]_vars /\ WF_vars(Next)

\* Every light shows the commanded phase or is exactly one step behind it.
InSynch == \A i \in Intersections : light[i] = phase \/ NextPhase(light[i]) = phase

\* No light goes straight from Green to Red.
NoGreenToRed == [][\A i \in Intersections :
                     light[i] = "Green" => light'[i] \in {"Green", "Yellow"}]_vars

\* Every light turns green infinitely often.
AlwaysEventuallyGreen == \A i \in Intersections : []<>(light[i] = "Green")
=============================================================================

TrafficLight.cfg, three intersections as in 2020:

CONSTANTS N = 3  RedTicks = 4  GreenTicks = 8  YellowTicks = 1
SPECIFICATION Spec
INVARIANTS TypeOK InSynch
PROPERTIES NoGreenToRed AlwaysEventuallyGreen
Model checking completed. No error has been found.
300 states generated, 128 distinct states found, 0 states left on queue.
The depth of the complete state graph search is 29.

InSynch is the 2020 page's invariant of the same name, stated as a predicate instead of a loop. To see that it is doing work, delete /\ AllCaughtUp from Advance and rerun. TLC finds a 15-state behaviour in which the controller ticks through all of red and green while no light has moved:

Error: Invariant InSynch is violated.
...
State 15: <Advance line 37, col 12 to line 40, col 29 of module TrafficLight>
/\ light = <<"Red", "Red", "Red">>
/\ timer = 1
/\ phase = "Yellow"

What the spec does not say: because the clock runs while lights lag, a slow light can show green for fewer than GreenTicks ticks. Bounding that needs either a per-light timer or a real-time module; neither is attempted here.

1.2. Communication protocol

verified · tlc-2.19 · 2026-10-09

Nodes send messages to each other through bounded buffers, one per destination and priority. A receiver always takes the head of its most urgent non-empty buffer (priority 1 is highest). Error handling is reduced to one rule: a send into a full buffer is not lost silently, the message moves to a dropped set, which stands in for the error report back to the sender. Each message's route is left open in the initial state, so TLC checks every assignment of source, destination and priority to the message ids, loopback included.

This one is PlusCal; pcal.trans fills in the translation between the BEGIN / END TRANSLATION markers.

-------------------------- MODULE PriorityProtocol --------------------------
EXTENDS Naturals, Sequences, FiniteSets

CONSTANTS Nodes, Priorities, Cap, MsgIds
ASSUME Priorities \subseteq Nat /\ Cap \in Nat /\ Cap > 0

Range(s) == {s[i] : i \in DOMAIN s}

(* --algorithm PriorityProtocol
variables
  route \in [MsgIds -> [src : Nodes, dst : Nodes, pri : Priorities]],
  pending = MsgIds,
  buf = [n \in Nodes |-> [p \in Priorities |-> <<>>]],
  delivered = [n \in Nodes |-> <<>>],
  dropped = {};

define
  Buffered(n) == UNION {Range(buf[n][p]) : p \in Priorities}
  Top(n) == CHOOSE p \in Priorities :
              /\ buf[n][p] # <<>>
              /\ \A q \in Priorities : q < p => buf[n][q] = <<>>
  Busy(n) == \/ \E m \in pending : route[m].src = n \/ route[m].dst = n
             \/ Buffered(n) # {}
end define;

fair process node \in Nodes
begin
Loop:
  while Busy(self) do
    either
      with m \in {x \in pending : route[x].src = self} do
        pending := pending \ {m};
        if Len(buf[route[m].dst][route[m].pri]) < Cap then
          buf[route[m].dst][route[m].pri] :=
            Append(buf[route[m].dst][route[m].pri], m);
        else
          dropped := dropped \union {m};
        end if;
      end with;
    or
      await Buffered(self) # {};
      with p = Top(self) do
        delivered[self] := Append(delivered[self], Head(buf[self][p]));
        buf[self][p] := Tail(buf[self][p]);
      end with;
    end either;
  end while;
end process;
end algorithm; *)
\* BEGIN TRANSLATION
\* END TRANSLATION

TypeOK == /\ pending \subseteq MsgIds
          /\ dropped \subseteq MsgIds
          /\ \A n \in Nodes : Range(delivered[n]) \subseteq MsgIds

BoundedBuffers == \A n \in Nodes, p \in Priorities : Len(buf[n][p]) <= Cap

\* Every message is in exactly one place: pending, buffered at its
\* destination, delivered at its destination, or dropped.
Conservation ==
  \A m \in MsgIds :
    LET d == route[m].dst IN
      (IF m \in pending THEN 1 ELSE 0)
    + (IF m \in Buffered(d) THEN 1 ELSE 0)
    + (IF m \in Range(delivered[d]) THEN 1 ELSE 0)
    + (IF m \in dropped THEN 1 ELSE 0) = 1

NoDuplicates == \A n \in Nodes : Len(delivered[n]) = Cardinality(Range(delivered[n]))

\* A delivery never skips over a more urgent buffered message.
PriorityRespected ==
  [][\A n \in Nodes :
       Len(delivered'[n]) > Len(delivered[n]) =>
         \A q \in Priorities :
           q < route[delivered'[n][Len(delivered'[n])]].pri => buf[n][q] = <<>>]_vars

\* Eventually nothing is pending or buffered: every message is delivered or dropped.
Settles == <>[](pending = {} /\ \A n \in Nodes : Buffered(n) = {})
=============================================================================

PriorityProtocol.cfg, deliberately tight so the drop path is exercised:

CONSTANTS Nodes = {1, 2}  Priorities = {1, 2}  Cap = 1  MsgIds = {1, 2, 3}
SPECIFICATION Spec
INVARIANTS TypeOK BoundedBuffers Conservation NoDuplicates
PROPERTIES PriorityRespected Settles
Finished computing initial states: 512 distinct states generated
Model checking completed. No error has been found.
59424 states generated, 34896 distinct states found, 0 states left on queue.
The depth of the complete state graph search is 9.

The 512 initial states are the route assignments: 8 possible routes per message, three messages. Replace with p = Top(self) by with p \in {q \in Priorities : buf[self][q] # <<>>} (take any non-empty buffer) and TLC reports PriorityRespected violated in four states: node 1 buffers message 1 at priority 1, then message 3 at priority 2, then delivers message 3 first.

Two things the model leaves out. Strict priority can starve the low-priority buffer when high-priority traffic never stops; with a finite MsgIds that cannot happen, so Settles holds here and says nothing about it. And dropped is a global set, not a message back to the sender, so a lost error report is not modelled.

2. Since then

The blocks that sat here until 2026 looked like a specification and had never been near a parser. The fix is mechanical: a spec that SANY has not parsed and TLC has not run is prose.

2.1. Tooling

correct · claude-opus-5.5 · 2026-10-09

Stewardship passed to the TLA+ Foundation, a Linux Foundation project (press release, 21 April 2023) with AWS, Oracle and Microsoft as founding members.

The TLA+ home page now points there, names TLC and the TLAPS proof system as the primary tools, and lists a VS Code extension alongside the Toolbox.

Apalache is a symbolic model checker for TLA+ that translates to SMT (Z3, CVC5), which helps when explicit-state TLC runs out of room. For specs this size, a jar and a JVM are still all that is needed.

2.2. On this site

TLA+ for System Design is the long treatment: PlusCal, industry case studies, and an order state machine. Modeling Apache Redirect Rules in TLA+ and TLA+ for Build Pipeline Verification apply TLC to small, real configuration problems on this site. Alloy Specification Language covers the relational alternative and models the same redirect-termination question; Logic for Programmers: a tools comparison places TLA+ among nine verification tools by install cost and guarantee. The Four-Boundary Spec Mapping files TLA+ under the composition boundary, next to session types and Dafny; Dafny spec to clojure.spec Transpiler is the nearest route from a checked spec to runtime checks.