Skip to main content
You’ve read that formal methods find bugs testing misses. You understand the theory. But when you sit down to write your first specification, the blank editor stares back. Where do you start? This guide walks through the complete process: starting with a simple program, identifying what to model, building specifications in both TLA+ and Alloy, and running verification. By the end, you’ll have written working formal models and found a real bug.

The Program: A Login System

Let’s model something familiar: a login system with lockout protection. Here’s the pseudocode:
Simple enough. Three states, a counter, some transitions. What could go wrong?

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 login
  • logged_in - authenticated, can logout
  • locked - too many failed attempts, blocked until admin reset
Transitions:
  • logged_outlogged_in (correct password)
  • logged_outlocked (third wrong password)
  • logged_inlogged_out (logout)
  • lockedlogged_out (admin reset)
Variables:
  • state - current authentication state
  • failed_attempts - counter (0-3)
Already questions emerge. What happens if someone tries to login while already logged in? What if there’s a race condition between login attempts? Can 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):
  1. A user should never be in logged_in state without providing the correct password
  2. failed_attempts should never exceed MAX_ATTEMPTS
  3. A locked account should not allow login (we’ll see this is guaranteed structurally)
Liveness properties (good things that should eventually happen): 4. A user with the correct password should eventually be able to log in 5. A locked account should eventually be resettable This tutorial focuses on safety properties. Liveness requires fairness assumptions (guaranteeing certain actions eventually occur) and temporal operators like <> (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:
The 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.
Notice 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:
These are state invariants: predicates that must hold in every reachable state. TLC checks them exhaustively. What about “can’t login from locked”? We don’t need an explicit invariant because it’s guaranteed by the action definitions. No action has both 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:
Note: We model password correctness abstractly -AttemptLoginCorrect and AttemptLoginWrong are separate actions rather than checking an actual password value. This abstraction keeps the model simple while capturing the essential behavior.
Run TLC. With 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.
Note that this model has no invariants and omits 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:
Alice gets locked out after only two of her attempts because Bob’s attempt counted against her. The invariant 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.
Each 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 uses fact blocks to constrain valid configurations:
These facts define what configurations are valid. 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 the LoggedInMeansNoFailures fact (prefix with --). Then add this predicate and run command:
Running this shows SAT (yes, it’s possible). Alloy displays the instance: a user in 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:
If Alloy finds a counterexample, it shows exactly how the assertion fails. Here it will find one: our fact says 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:
This passes because our LoggedInMeansNoFailures fact guarantees it. Assertions let you verify that facts actually enforce the properties you intend.

Temporal Properties in Alloy 6

Alloy 6 adds temporal operators for behavioral modeling. To use them, mark fields with var:
The 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 trans requires a valid transition in every state; stutter ensures one always exists.
  • Step bounds: but 10 steps limits trace length for bounded model checking.

Comparing the Approaches

Alloy 6 added temporal operators (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:
  1. Sketch the state space. What are the states? What are the transitions? Draw it on paper first.
  2. Identify variables. What data needs to be tracked? What are their valid ranges?
  3. Write properties first. What should always be true? What should never happen? English first, then formalize.
  4. Start simple. Single user, small constants. Get the basic model working before adding complexity.
  5. Add complexity incrementally. Concurrency, multiple users, edge cases. Each addition may reveal new bugs.
  6. 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:
  1. Per-user vs. global counter ambiguity. The original design didn’t specify, so a real implementation might get it wrong.
  2. Counter reset timing. Should failedAttempts reset on successful login? The code says yes, but did we test it?
  3. Concurrent access behavior. Two users interleaving attempts breaks assumptions about lockout.
None of these required writing or running any code. The formal model exposed design issues before implementation began.

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.