Two checks per model: start, and every step
Each model is checked twice
(scripts/verify-models.ts):
const checks = [
{ init: "Init", length: 0, name: "initial state" },
{ init: "InductionStart", length: 1, name: "every legal state and next edge" },
];
// apalache-mc check --init=<init> --next=Next --inv=Safety --length=<n> <Model>.tla
Init with length 0: the starting state is safe.
InductionStart with length 1, where InductionStart == Safety:
from any state that satisfies Safety, any single step lands
in a state that still satisfies Safety.
Together those two are an induction proof. The property holds at the
start, and no step can break it, so it holds after any number of
steps. This is why the design guide can say the checks "establish
model safety for any run length", rather than up to some bound.
Make the checker prove it can fail
A model checker that says "no errors" is only useful if it would have
said something else when there was an error. A broken data
generator, a typo in an invariant, or an always-false guard can all
make every check pass. So verify:models corrupts the data on
purpose and requires a counterexample:
// The checker must reject corrupt data. A pass here would make the
// clean verdicts worthless.
console.log("Check JointState: corrupted edge counterexample.");
// ...writes JointStateData.tla from corruptOneEdge(model)...
const corrupted = await runCheck("JointState", "InductionStart", 1);
if (corrupted.success) {
throw new Error(
"The checker accepted a corrupted edge. The safety verdict is not trusted.",
);
}
corruptOneEdge takes one real money edge and flips the fact that it
keeps its refund request. BookingReplay gets the same treatment for
both failure modes it rules out: a duplicate booking, and a refund
after the temporary row was deleted. This is mutation testing for
proofs.
Proofs are code, so treat them like code
- Readable rules. "Give each fact named record fields. Use short
rule names that describe the business condition. Prefer explicit
conditions to dense Boolean formulas."
RiskyMoneyKeepsQueuedWork
is a sentence an operator could read.
- Shared helpers. Common rules live in
ModelRules.tla,
for example ChecksStillPass: "A later success cannot erase an
earlier failure."
- Duplication checked.
deno task cpd includes a TLA+ adapter that
runs jscpd over every model.
- Types beside declarations. Apalache type notes sit next to each
variable, and each fact's schema is declared once in its generator,
from which both the TypeScript type and the TLA+ type note derive.
- Pinned tools. Apalache and its Java runtime come from devenv,
so the model check runs the same in precommit and CI.
Say what the proof does not prove
The design guide lists the limits, and that list matters as much as
the proofs:
The models trust representative coverage and event annotations. [...]
Model safety does not prove SQL atomicity, provider truth or eventual
task execution.
Each of those gaps has its own check: the
conformance sweep
for representatives, database tests for atomicity, the
nightly sandbox runs for
provider truth, and the live anomaly scan for stored data. A proof
that claims to cover everything is a proof nobody has read closely.