TLA PreCheck
What This Is
TLA PreCheck is a restricted TypeScript DSL with TLA+ semantics.
You write machines in .machine.ts files. You do NOT write .tla files.
The compiler generates:
- TLA+ spec for TLC model checking (exhaustive proof)
- TypeScript interpreter for runtime execution
- Postgres DDL for database-level enforcement
- Generated adapter with typed per-action functions
The key guarantee: for a chosen finite proof tier, the generated TLA+ and the generated TypeScript interpreter reach the same state graph. Not similar - identical. Mathematically verified.
Think in TLA+, write in TypeScript.
Design Rules
- One risky workflow per machine. Don't model your whole system. Model the billing state machine. Model the subscription lifecycle. One machine per critical flow.
- Keep proof domains tiny. 2 users, 3 runs finds most bugs. Scale up in nightly tiers.
- Fix the design, not the code. If
checkfails, the state machine design is wrong. Redesign the transitions and invariants. - Never edit generated artifacts. Don't touch
.tlafiles, certificates, or adapter code. Regenerate withbuild. - Never write directly to machine-owned tables. All mutations go through the generated adapter or interpreter.
- Prefer small atomic machines. Multiple small machines composed at the application layer beat one giant spec.
The Three Commands
# 1. Start a new machine
npx tla-precheck init
# 2. Design loop - run until it passes
npx tla-precheck check <machine>
# 3. Ship it - generates adapter + all artifacts for adapter-capable machines
npx tla-precheck build <machine>
The design loop:
- Write/edit the
.machine.tsDSL - Run
check- it validates, estimates state space, then runs TLC - If the model checker finds a bug (invariant violation, stuck state), it tells you exactly what sequence of events caused it
- Fix the DSL - not a code patch, a design fix
- Repeat until
checkpasses - If the machine declares
metadata.runtimeAdapter,metadata.ownedTables, andmetadata.ownedColumns, runbuildto generate the adapter and all artifacts - Import the generated adapter functions into your codebase
DSL Quick Reference
Machines have variables, actions, invariants, and proof tiers.
If you want build to generate a database adapter, the machine also needs adapter metadata.
Variables
// Scalar: one value
status: scalarVar(enumType("draft", "active", "done"), lit("draft"))
// Map: one value per domain element (like a column per row)
status: mapVar("Runs", enumType("idle", "running", "done"), lit("idle"))
owner: mapVar("Runs", optionType(domainType("Users")), lit(null))
Actions
activate: {
params: { r: "Runs" }, // bound parameters
guard: eq(index(status, param("r")), lit("idle")), // when is this allowed?
updates: [setMap("status", param("r"), lit("running"))] // what changes?
}
Invariants
oneActivePerUser: {
description: "At most one running item per user",
formula: forall("Users", "u", lte(
count("Runs", "c", and(
eq(index(owner, param("c")), param("u")),
eq(index(status, param("c")), lit("running"))
)),
lit(1)
))
}
Proof Tiers
proof: {
defaultTier: "pr",
tiers: {
pr: {
domains: {
Users: modelValues("u", { size: 2, symmetry: true }),
Runs: ids({ prefix: "r", size: 3 })
},
budgets: { maxEstimatedStates: 10_000 }
}
}
}
For the full DSL reference, see references/dsl-cheatsheet.md.
For the complete CLI workflow, see references/cli-workflow.md.
What "Done" Means
The machine is verified when:
checkpasses - TLC exhaustively explored every reachable state- The equivalence certificate says
equivalent: true buildsucceeds - adapter generated from the proven machine when adapter metadata is declared
After build, your codebase imports typed functions:
import { create, cancel, complete } from "./machine-adapters/MyMachine.adapter";
await create(sql, { u: userId, r: runId });
Each function opens a transaction, locks rows, runs the proven interpreter, diffs state, and writes changes. No hand-written guard logic. No hallucination surface.
Runtime Boundary
- The interpreter IS the runtime semantics - not an advisory check
- The generated adapter is the preferred mutation path when the machine fits the adapter subset (single owned table, all mapVars, one row domain)
buildrequires explicit database mapping metadata:metadata.runtimeAdaptermetadata.ownedTablesmetadata.ownedColumns
- If the adapter subset doesn't fit, call the interpreter manually:
import { resolveMachine } from "tla-precheck/proof"; import { buildInitialState, enabled, step } from "tla-precheck/interpreter"; const resolved = resolveMachine(myMachine, "pr"); const current = buildInitialState(resolved); if (!enabled(resolved, current, "activate", { r: "r1" })) { throw new Error("Transition not enabled"); } const next = step(resolved, current, "activate", { r: "r1" }); - Storage constraints (Postgres partial unique indexes, CHECK constraints) back cross-row invariants at the database level