stateproof
stateproof lets you write a model in TypeScript and check every reachable state. It compiles a subset of TypeScript to TLA+, is MIT licensed, is not on npm, and its own README example fails.
Using stateproof
Section titled “Using stateproof”The code below is in
formal-methods-samples/stateproof-subscription. We ran it from the repository at commit 4cb6308 (March 2026), on
Node 24 with Java 25. The output is from our run.
The problem
Section titled “The problem”renew also runs on canceling plans, so a canceled plan is charged and active again.A subscription service lets a customer cancel at any time. The plan then runs to the end of the paid period, and until then the customer can resume it. At the end of each period a renewal job charges every live plan and keeps it active:
// subscription.ts (shortened).transition('renew', { from: ['active', 'canceling'], to: 'active' })“Live” was taken to include plans that are canceling. So a plan canceled during the period is charged again and made active at the next renewal, as if the customer had never canceled.
Install and set up
Section titled “Install and set up”The README says pnpm add @stateproof/core, but the package is not on npm. It runs from source; the sample’s
setup script clones it at the commit above:
git clone https://github.com/HexaField/stateproofNeeds Java for TLC.
Writing the machine (use AI)
Section titled “Writing the machine (use AI)”import { machine } from '@stateproof/core'
export const subscription = machine('Subscription') .states('trial', 'active', 'canceling', 'canceled') .initial('trial') // Flat data next to the state: set when the customer cancels. .context({ canceledByUser: false }) .transition('activate', { from: 'trial', to: 'active' }) .transition('cancel', { from: 'active', to: 'canceling', action: (ctx) => { ctx.canceledByUser = true } }) .transition('resume', { from: 'canceling', to: 'active', action: (ctx) => { ctx.canceledByUser = false } }) .transition('renew', { from: ['active', 'canceling'], to: 'active' }) .transition('expire', { from: 'canceling', to: 'canceled' }) .transition('resubscribe', { from: 'canceled', to: 'active', action: (ctx) => { ctx.canceledByUser = false } }) // Must hold in every reachable state. .invariant('canceled plans are not renewed', (ctx) => !(ctx.canceledByUser && ctx.state === 'active'))Guards, actions and invariants are not run by the checker. stateproof reads their source text with fn.toString()
and translates it into TLA+. The renew line above became:
renew == /\ state \in {"active", "canceling"} /\ state' = "active" /\ UNCHANGED canceledByUserThe same machine also runs in your code, and there the bug is real. createRuntime(subscription), then activate,
cancel and renew, leaves the runtime in active with canceledByUser still true; the sample’s test checks it.
Running it
Section titled “Running it”// verify.ts (shortened)const result = await verify(subscription, { tlcPath: resolve(jar) })The result, without the generated tlaSpec and the raw tlcOutput:
{ "ok": false, "statesExplored": 6, "distinctStates": 4, "depth": 4, "duration": 404, "violations": [ { "invariant": "canceled_plans_are_not_renewed", "trace": [ { "action": "state", "state": { "state": "trial", "canceledByUser": false }, "expectedState": "trial" }, { "action": "state", "state": { "state": "active", "canceledByUser": false }, "expectedState": "active" }, { "action": "state", "state": { "state": "canceling", "canceledByUser": true }, "expectedState": "canceling" }, { "action": "state", "state": { "state": "active", "canceledByUser": true }, "expectedState": "active" } ], "type": "invariant" } ]}The trace is activate, cancel, renew. Every step says "action": "state", so which transition ran has to be read
from the difference between two states. The invariant’s name comes back with its spaces turned into underscores.
Fixing the bug
Section titled “Fixing the bug”ok: true, 4 states.The fix: the renewal job now renews active plans only; a canceling plan runs out and expires:
// A subscription: a canceled plan runs to the end of its period, and the customer can resume it until then.import { machine } from './vendor/stateproof/packages/core/src/index.ts';
export const subscription = machine('Subscription') .states('trial', 'active', 'canceling', 'canceled') .initial('trial') // Flat data next to the state: set when the customer cancels. .context({ canceledByUser: false }) .transition('activate', { from: 'trial', to: 'active' }) .transition('cancel', { from: 'active', to: 'canceling', action: (ctx) => { ctx.canceledByUser = true } }) .transition('resume', { from: 'canceling', to: 'active', action: (ctx) => { ctx.canceledByUser = false } }) .transition('renew', { from: ['active', 'canceling'], to: 'active' }) .transition('expire', { from: 'canceling', to: 'canceled' }) .transition('resubscribe', { from: 'canceled', to: 'active', action: (ctx) => { ctx.canceledByUser = false } }) // Must hold in every reachable state. .invariant('canceled plans are not renewed', (ctx) => !(ctx.canceledByUser && ctx.state === 'active'))// A subscription: a canceled plan runs to the end of its period, and the customer can resume it until then.import { machine } from './vendor/stateproof/packages/core/src/index.ts';
export const subscription = machine('Subscription') .states('trial', 'active', 'canceling', 'canceled') .initial('trial') // Flat data next to the state: set when the customer cancels. .context({ canceledByUser: false }) .transition('activate', { from: 'trial', to: 'active' }) .transition('cancel', { from: 'active', to: 'canceling', action: (ctx) => { ctx.canceledByUser = true } }) .transition('resume', { from: 'canceling', to: 'active', action: (ctx) => { ctx.canceledByUser = false } }) .transition('renew', { from: ['active', 'canceling'], to: 'active' }) .transition('renew', { from: 'active', to: 'active' }) .transition('expire', { from: 'canceling', to: 'canceled' }) .transition('resubscribe', { from: 'canceled', to: 'active', action: (ctx) => { ctx.canceledByUser = false } }) // Must hold in every reachable state. .invariant('canceled plans are not renewed', (ctx) => !(ctx.canceledByUser && ctx.state === 'active'))// A subscription: a canceled plan runs to the end of its period, and the customer can resume it until then.import { machine } from './vendor/stateproof/packages/core/src/index.ts';export const subscription = machine('Subscription').states('trial', 'active', 'canceling', 'canceled').initial('trial')// Flat data next to the state: set when the customer cancels..context({ canceledByUser: false }).transition('activate', { from: 'trial', to: 'active' }).transition('cancel', {from: 'active',to: 'canceling',action: (ctx) => {ctx.canceledByUser = true}}).transition('resume', {from: 'canceling',to: 'active',action: (ctx) => {ctx.canceledByUser = false}}).transition('renew', { from:['active','canceling'],to: 'active' }).transition('expire', { from: 'canceling', to: 'canceled' }).transition('resubscribe', {from: 'canceled',to: 'active',action: (ctx) => {ctx.canceledByUser = false}})// Must hold in every reachable state..invariant('canceled plans are not renewed', (ctx) => !(ctx.canceledByUser && ctx.state === 'active'))
// A subscription: a canceled plan runs to the end of its period, and the customer can resume it until then.import { machine } from './vendor/stateproof/packages/core/src/index.ts';
export const subscription = machine('Subscription') .states('trial', 'active', 'canceling', 'canceled') .initial('trial') // Flat data next to the state: set when the customer cancels. .context({ canceledByUser: false }) .transition('activate', { from: 'trial', to: 'active' }) .transition('cancel', { from: 'active', to: 'canceling', action: (ctx) => { ctx.canceledByUser = true } }) .transition('resume', { from: 'canceling', to: 'active', action: (ctx) => { ctx.canceledByUser = false } }) .transition('renew', { from: 'active', to: 'active' }) .transition('expire', { from: 'canceling', to: 'canceled' }) .transition('resubscribe', { from: 'canceled', to: 'active', action: (ctx) => { ctx.canceledByUser = false } }) // Must hold in every reachable state. .invariant('canceled plans are not renewed', (ctx) => !(ctx.canceledByUser && ctx.state === 'active')){ "ok": true, "statesExplored": 7, "distinctStates": 4, "depth": 4, "duration": 261, "violations": []}The machine has no end state: resubscribe leads out of canceled. That matters, because deadlock checking is on by
default and a state with no way out fails the check (see Limitations).
Project layout and CI
Section titled “Project layout and CI”./├── .github/workflows/ci.yml├── src/subscription.ts the machine; your code also runs it with createRuntime├── stateproof/│ ├── setup.ts clones stateproof at a fixed commit│ └── verify.ts runs verify() and sets the exit code└── vendor/stateproof/ the clone, not committedThe TLA+ copy is written to a temp folder on each run, so nothing generated is committed. CI needs Java. stateproof
downloads tla2tools.jar if it does not find one, and Node 24 runs the .ts files as they are:
The CI workflow: .github/workflows/ci.yml (9 lines)
# .github/workflows/ci.yml (steps)- uses: actions/setup-node@v7 with: { node-version: 24 }- uses: actions/setup-java@v6 with: { distribution: temurin, java-version: 21 }- run: node stateproof/setup.ts# Without Java or TLC, verify() returns ok: true and 0 states. So verify.ts ends with# process.exit(result.ok && result.statesExplored > 0 ? 0 : 1)- run: node stateproof/verify.tsWhat fails when:
- A change breaks a rule:
verify.ts, with the trace to the bug. - A change adds a state with no way out:
verify.ts, as adeadlockviolation, unless you passcheckDeadlocks: false. - Java or TLC is missing:
verify.ts, but only because of thestatesExploredcheck.
Making changes
Section titled “Making changes”- Change the machine in
subscription.ts. Your code runs the same machine, so there is no second copy to update. - For a new rule, add an
.invariant(...). - Run
verify.ts. In a trace, find which transition ran from the difference between two states. - For a new guard or action, read the TLA+ in the result’s
tlaSpec. The translation can be wrong without an error. - Commit the machine.
Strengths
Section titled “Strengths”- It checks every reachable state. A TLA+ copy is made from your builder code and checked by TLC.
- Strong on “eventually” rules. They work through TLC.
- It does more than check. It can also draw diagrams, compare versions of a spec and check a real implementation.
Limitations
Section titled “Limitations”- You have to trust its translation to TLA+. It covers only code the translator understands, and it can be silently
wrong (for example,
nullbecomes the string"null"), so what TLC checks and what runs can differ. ok: trueis not enough. A run where TLC could not start still saysok: true; checkstatesExploredtoo.- Timing and async are not covered. The check does not look at async code.
- It needs Java and some setup. You give it the full path to the TLC jar, and actions that take arguments must be written in raw TLA+.
- A final state fails by default. It counts a final state as a deadlock until you pass
checkDeadlocks: false; this is why the README’s quick start fails.
- Built by Josh Field (HexaField), a TypeScript and WebXR engineer, as a side project with one contributor; no activity since March 2026.
- stateproof on GitHub: https://github.com/HexaField/stateproof
- HexaField on npm: https://www.npmjs.com/~hexafield
- HexaField on StackBlitz: https://stackblitz.com/@HexaField
- HexaField on X: https://x.com/HexaField