The Program: A Login System
Let’s model something familiar: a login system with lockout protection. Here’s the pseudocode:Step 1: Identify States and Transitions
Before writing any specification, sketch the state machine on paper. What are the possible states? What causes transitions between them? States:logged_out- initial state, can attempt loginlogged_in- authenticated, can logoutlocked- too many failed attempts, blocked until admin reset
logged_out→logged_in(correct password)logged_out→locked(third wrong password)logged_in→logged_out(logout)locked→logged_out(admin reset)
state- current authentication statefailed_attempts- counter (0-3)
failed_attempts ever exceed MAX_ATTEMPTS?
Formal modeling forces you to answer these questions precisely.
Step 2: Define Properties to Verify
What should be true about this system? Write these in plain English first: Safety properties (bad things that should never happen):- A user should never be in
logged_instate without providing the correct password failed_attemptsshould never exceedMAX_ATTEMPTS- A locked account should not allow login (we’ll see this is guaranteed structurally)
<> (eventually), covered in the TLA+ Syntax Guide.
Now let’s translate safety properties into formal specifications.
TLA+ Specification
TLA+ models systems as state machines: an initial state, and actions that transition between states. Here’s our login system:Init predicate defines the starting state. The TypeInvariant constrains variables to valid ranges. If verification ever finds a state outside these bounds, something is wrong with our model.
Modeling Actions
Each action in TLA+ is a predicate describing when it can happen (precondition) and what changes (postcondition). Unprimed variables refer to the current state; primed variables (state') refer to the next state.
AttemptLoginWrong checks the threshold against failedAttempts' (the post-increment value), not the original.
The Complete Specification
Next is the disjunction of all possible actions; any one can happen at each step. Spec says: start in Init, then repeatedly take Next steps (or stutter, staying in the same state).
Safety Properties in TLA+
Now we formalize our safety properties as invariants:state = "locked" as a precondition and state' = "logged_in" as a postcondition. The structure of Next makes this transition impossible. This is a key insight: well-designed actions can make certain invariants unnecessary.
Running TLC
Create a model configuration file or use the TLA+ Toolbox:AttemptLoginCorrect and AttemptLoginWrong are separate actions rather than checking an actual password value. This abstraction keeps the model simple while capturing the essential behavior.
Complete runnable specification
Complete runnable specification
MaxAttempts = 3, it explores every reachable state in milliseconds.
Result: All properties hold. The model checker proves that no sequence of actions can violate our invariants.
Finding a Bug: Shared State
The single-user model works. But what about concurrent access? Let’s extend the model for two users trying to authenticate simultaneously.Logout, deliberately. It’s a minimal model focused on login attempts to demonstrate that passing invariants doesn’t mean the design is correct.
With Users = {"alice", "bob"} and MaxAttempts = 3, run TLC in simulation mode (or examine the state graph in the TLA+ Toolbox). Since no invariant is violated, TLC won’t produce an error trace automatically, but simulation shows possible behaviors. Here’s one trace you might observe:
failedAttempts <= MaxAttempts still holds (the counter reaches 3, not 4). But the design is broken: lockout punishes the wrong user.
To catch this formally, we’d need a per-user invariant. But even without one, exploring the state space reveals the flaw. This is a key insight: formal modeling exposes design issues even when invariants pass.
The fix: failedAttempts should be a function mapping users to their individual counters. Formal modeling found this before any code was written.
Alloy Specification
Alloy approaches the same problem differently. Instead of exploring state transitions, it searches for structural configurations that satisfy (or violate) constraints. Alloy 6 adds temporal operators, but let’s start with pure structural analysis.User has a currentState and a failedAttempts counter. Alloy will search for configurations (assignments of values to these fields) that satisfy our constraints.
Note on integers: Alloy’s Int is bounded by bitwidth (default 4 bits: -8 to 7). For small counters like failedAttempts, this is fine. For larger values, specify a higher bitwidth with run ... for 5 Int or use a custom sig instead of Int.
Facts as Constraints
Alloy usesfact blocks to constrain valid configurations:
LockedMeansMaxAttempts ensures lockout only happens after three failures. LoggedInMeansNoFailures ensures the counter resets on successful login. Notice we don’t cap failedAttempts; the counter can keep incrementing after lockout.
Finding Counterexamples
The power of Alloy is asking “can this bad thing happen?” Let’s see what happens when we forget a constraint. First, temporarily comment out theLoggedInMeansNoFailures fact (prefix with --). Then add this predicate and run command:
LoggedIn state with failedAttempts = 2. Without the LoggedInMeansNoFailures constraint, nothing prevents this invalid configuration.
Now restore the fact (remove the --) and run again: UNSAT. The bad configuration is now impossible. This workflow (remove a constraint, see what breaks, add it back) helps you understand what each constraint contributes.
Checking Assertions
Turn properties into assertions that Alloy tries to disprove:failedAttempts >= 3, not = 3. A locked user could have failedAttempts = 5 (satisfies the fact) but violates the assertion. This reveals a modeling decision: should the counter stop at 3 or keep incrementing?
A more useful assertion:
LoggedInMeansNoFailures fact guarantees it. Assertions let you verify that facts actually enforce the properties you intend.
Complete runnable structural specification
Complete runnable structural specification
Temporal Properties in Alloy 6
Alloy 6 adds temporal operators for behavioral modeling. To use them, mark fields withvar:
var keyword marks mutable fields. The always operator and primed variables (currentState') enable behavioral specification similar to TLA+.
Key patterns in Alloy 6 temporal mode:
- Frame conditions: Explicitly state what doesn’t change. Without
frame[u], uninvolved users could change arbitrarily. - Stutter steps: Allow the system to remain in its current state. Alloy 6 traces are infinite. Without stutter, if no action is enabled, no valid trace exists and analysis returns UNSAT.
- Init predicate: Constrains only the first state. The
always transrequires a valid transition in every state;stutterensures one always exists. - Step bounds:
but 10 stepslimits trace length for bounded model checking.
Comparing the Approaches
| Aspect | TLA+ | Alloy (structural) | Alloy 6 (temporal) |
|---|---|---|---|
| Model | State transitions | Structural constraints | State transitions |
| Finds | Execution traces that violate properties | Configurations that violate assertions | Traces or configurations |
| Strength | Temporal sequences, liveness | Counterexample visualization | Both structural and temporal |
| Output | Step-by-step trace | Visual graph of instance | Visual trace or graph |
var, always, primed variables), giving it TLA+-like capabilities. The structural mode remains powerful for exploring data models; the temporal mode enables behavioral verification.
Use TLA+ when you care about sequences: “Does every lockout eventually get reset?” requires temporal reasoning about what must happen over time.
Use Alloy structural mode when you care about structure: “Can a locked user have zero failed attempts?” is a constraint satisfaction question.
Use Alloy temporal mode when you want both: behavioral verification with Alloy’s visualization and relational operators.
For the login system, each tool revealed different aspects. TLA+ exposed the shared-counter flaw through state exploration. Alloy exposed missing constraints by finding configurations we hadn’t explicitly forbidden.
The Modeling Process
Here’s the general workflow, now that you’ve seen it in action:- Sketch the state space. What are the states? What are the transitions? Draw it on paper first.
- Identify variables. What data needs to be tracked? What are their valid ranges?
- Write properties first. What should always be true? What should never happen? English first, then formalize.
- Start simple. Single user, small constants. Get the basic model working before adding complexity.
- Add complexity incrementally. Concurrency, multiple users, edge cases. Each addition may reveal new bugs.
- Trust the counterexamples. When the model checker finds a violation, it’s real (in your model). Either fix the model or fix the design.
What We Found
Starting from simple pseudocode, formal modeling revealed:- Per-user vs. global counter ambiguity. The original design didn’t specify, so a real implementation might get it wrong.
-
Counter reset timing. Should
failedAttemptsreset on successful login? The code says yes, but did we test it? - Concurrent access behavior. Two users interleaving attempts breaks assumptions about lockout.
Next Steps
You’ve built your first formal model. Here’s where to go next: Deepen your TLA+ knowledge. The TLA+ Basics guide covers temporal operators, fairness, and more complex examples. Explore Alloy’s relational power. The Alloy Basics guide demonstrates structural modeling with file systems and permissions. Model something real. Pick a system you work with. A queue processor. A permission system. An API state machine. The act of modeling will reveal assumptions you didn’t know you were making. The login system was 30 lines of pseudocode. The formal model was about the same size. But the model checked millions of states and found bugs that testing would likely miss. That’s the value proposition: similar effort, much stronger guarantees.Complete specifications from this guide are available in the examples repository. Download MacTLA or MacAlloy to run them locally.