Interlock

spec/: TLA+ model of interlock_runtime and the two Temporal baselines

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.

Files

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

Run

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/.

Constants actually run

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.

State spaces

@@STATES@@

Modeling choices (read before trusting a result)

Reading two invariants correctly

Invariants that are n/a for the Temporal modes

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.

Changes to the spec’s model plan