A model check for the stated constants, not a proof of the Python or SQL. It checks the protocol in docs/07-runtime.md (sections 5 and 10) with bounded workers, clock, crashes and world events.
| file | what |
|---|---|
Runtime.tla |
claims, fence, step rows, gated effect (T_dispatch, send, T_commit, late result), recovery by tier, target with dedup store, world events, and MODE = interlock, temporal_idem, temporal_precheck |
Signals.tla |
suspend transaction, rt.signal, timers (NoLostWakeup, SignalExactlyOnceConsumed, TimerNotEarly) |
Runtime.cfg |
interlock mode, every safety invariant |
RuntimeTemporalIdem.cfg |
Temporal alone, key = run id + activity id |
RuntimeTemporalPrecheck.cfg |
Temporal plus the pre-send check of 11.1 (b), key = workflow id |
RuntimeLive.cfg |
Termination and EffectResolved under weak fairness |
Signals.cfg |
Signals invariants |
assumptions/Pause.cfg, assumptions/ServerSlow.cfg |
A1 and A2 violated on purpose |
broken/*.cfg |
deliberately broken designs; each must produce a counterexample |
COUNTEREXAMPLES.md |
every counterexample that matters, with its trace and the design fix |
run_tlc.sh |
downloads tla2tools.jar to .tools/ (gitignored), checks its sha256, runs TLC |
tla2tools.sha256 |
pinned hash of the jar |
Java: /opt/homebrew/opt/openjdk/bin/java (the java on PATH is broken on this machine; override with JAVA=).
TLC: 2026.09.12.025210 (rev: 867aefb), from the rolling v1.8.0 release asset, pinned by sha256.
One config (from the repo root):
spec/run_tlc.sh Signals Signals.cfg
spec/run_tlc.sh Signals broken/WakeOnlyIfSleeping.cfg
spec/run_tlc.sh Runtime Runtime.cfg # interlock, tier 2, events G1
spec/run_tlc.sh Runtime broken/NoLocalLeaseCheck.cfg
spec/run_tlc.sh Runtime RuntimeLive.cfg
TLC_WORKERS=4 TLC_HEAP=8g spec/run_tlc.sh Runtime RuntimeTemporalPrecheck.cfg
Everything, with per-invariant outcomes, traces and results/runtime_tlc.md:
python3 experiments/runtime_tlc.py suite --jobs 3
python3 experiments/runtime_tlc.py one Runtime.cfg --set Tier=1 --set Queryable=FALSE --inv PayloadBound
python3 experiments/runtime_tlc.py one RuntimeTemporalPrecheck.cfg --inv NoSendUnderRevokedGrant,EffectAtMostOnceTier12 --peel
A --peel run checks a list of invariants, removes each one TLC reports violated, and re-checks the
rest until they hold, so one full exploration answers every invariant and every violation keeps its
shortest trace. Configs actually run are written to spec/.tools/cfg/.
The spec’s base constants (10.1) are MaxClock = 12, MaxCrashes = 2, DedupAge = 6, all six events.
That space did not finish: at tier 2 with three events and one crash, MaxClock 8 is 6.6M distinct
states (65 s) and MaxClock 10 is 43.1M (7 min); each clock tick multiplies the space by about 2.5.
So the runs use:
| constant | run value | why |
|---|---|---|
| Workers | {w1, w2}, with symmetry | as specified |
| MaxClock | 10 (8 for witnesses and the two-crash runs, 9 for the assumption toggles, 20 for liveness) | state space |
| LeaseTTL, SendTimeout, SettleMargin | 3, 2, 2 (one run with LeaseTTL 2 < SendTimeout 3, the relation 5.4 recommends) | as specified |
| DedupAge | 5 | recovery starts at least SendTimeout + SettleMargin + 1 = 5 after dispatch; 5 keeps the tier-1 retry reachable and the downgrade reachable |
| MaxCrashes | 1 (2 in interlock_*_crashes2 at MaxClock 8) |
state space |
| Events | G1 = {revoke, hand_refund_full, cancel} and G2 = {prune_keys, redecide, revoke}, one run each | state space; revoke is in both so it meets every other event |
| broken configs | MaxClock 10, one crash, only the events the bug needs | the counterexample is shallow; a bigger space only slows BFS |
A partial hand refund behaves exactly like a full one in this model (both falsify the decision’s
premise “no prior refunds”, and both pass (b)’s eligibility check hand + 20 <= 100), so it is left out
of the groups. It is still an action in the spec.
Tier cases in every table: t1q tier 1 with lookup (Stripe-like), t1n tier 1 without lookup,
t2 tier 2, t3 tier 3.
@@STATES@@
now is Postgres time. now() is the transaction start: T_dispatch is two steps,
TDispatchBegin (fence UPDATE, row lock, now() fixed) and TDispatchCommit (every check of 5.7 (c),
atomically), so time can pass inside it. Other transactions are one atomic step.wf.lock): claims, cancel and fenced writes wait for an
open T_dispatch. The grant FOR SHARE lock is modeled by doing the grant check at commit, so a
revoke is either before the dispatch commit or after it.Send only before the sender’s local deadline (unless it
resumed from a pause). The target processes it at most SettleMargin ticks later (A2), or any time up
to MaxClock under SERVER_SLOW; the clock cannot pass a request’s bound. The response reaches the
sender when processed if the sender still waits for it; a lost response is a sender that never acts
on it. Tier 1 dedupes with Stripe semantics (same key and payload replays, different payload errors).ReadPremise, A3), so NoSendOnStalePremise is
judged at the read. PremiseTrueAtSend (not in 2.1) judges the premise at the moment the bytes leave
and shows the A3 window in every mode. Local SQL premises are not modeled separately: they are checked
at the same commit as the grant and behave like it.liveClaim is whether the fence of the authorizing transaction
matched a live claim (2.1’s definition), grantOk, afterRevoke and afterCancel are true at that
commit, prevSendSettled is “no earlier request for this effect is still unprocessed” at the send.
For Temporal, the authorizing point is the send itself (there is no transaction before it).T_dispatch.
Temporal: a reset to before the decision, a new run id, payload 30.DedupAge). A violation is a retry that could double
apply, not one that did. Example: both Temporal modes violate it at t1n with events G1, where no key
is ever pruned, so EffectAtMostOnceTier12 still holds there. With G2 (pruning) the risk can become a
real duplicate, which EffectAtMostOnceTier12 then reports.T_dispatch commit is missed. That is the conceded A3 window, not a bug.NoRerunAfterComplete, StepResultUnique (plain step not modeled); FencedWrites, LeaseMutex, TakeoverOnlyAfterExpiry (no fence or claim in the baseline); AmbiguousOnlyWhenUnknowable (never writes AMBIGUOUS); EffectCheckpointAtomic, LateResultPreserved, ReceiptChainLinear (no effect journal separate from the history; the gap EffectCheckpointAtomic guards shows up as the duplicate in EffectAtMostOnceTier12). 10.6 predicted V for EffectCheckpointAtomic in both Temporal modes; this model reports it as not applicable instead of forcing a violation.
broken/NoLocalLeaseCheck.cfg and broken/ThinDedupMargin.cfg for the two design findings (C1, C2 in COUNTEREXAMPLES.md). The interlock mode includes both fixes.Reach_* witnesses: each must be violated where its path exists, so a hold is not vacuous.PremiseTrueAtSend (A3 window) and a liveness sanity run with a too-short clock (must violate Termination).now > send_deadline + settle_margin and an unchanged dispatch pair under the effect row lock, and the
late result is a compare-and-set on that pair. The fence is still required: without it a zombie
writes step rows and workflow status (FencedWrites) and authorizes sends without a live claim
(SendRequiresLiveClaim). This is a diagnosed disagreement with the prediction, not a model bug: the
model exercises zombies (the same config reaches FencedWrites in 239 states).Reach_TwoSends is
unreachable at t2 while Reach_RecoveryResend is reachable: a tier-2 resend happens only when the
lookup, run after send_deadline + settle_margin, finds nothing, which means the first request never
left. At t1q two requests do occur (the idempotent retry), and the invariant is exercised there.