Skip to content

TLA+ verification redesign: proof obligations, exact reductions, honest scheduled gates - #5

Open
zsumz wants to merge 19 commits into
mainfrom
tla-improvements
Open

TLA+ verification redesign: proof obligations, exact reductions, honest scheduled gates#5
zsumz wants to merge 19 commits into
mainfrom
tla-improvements

Conversation

@zsumz

@zsumz zsumz commented Aug 12, 2026

Copy link
Copy Markdown
Owner

What this is

The TLA+ tier ladder gated everything on one monolithic model per profile, and the scheduled tiers gated on an event that measurement shows cannot occur: the nightly frontier grows at ~2.85 new states per retired state with no inflection, so the continuation can never drain its queue. This branch turns the layer into one primary configuration plus a manifest of focused proof obligations — each a strict disjunct-subset of the protocol relation, each passing only on states_left = 0 at exact measured floors — and makes the scheduled monolith continuation honest reporting instead of an unpassable gate.

Every architectural claim in here is backed by a measurement recorded in docs/model-checking.md; the short version:

  • Wired and exhausting: core replication (2 tiers of bounds), read fencing, and — after the snapshot fold made it drain — the full snapshot lifecycle. PR runs 2 obligations (~45 s), nightly 3, weekly 8 including an unsymmetrized twin of every gating theorem plus an all-feature integration model (the standing symmetry audit; each unsym/sym count pair differs from group-order-times-quotient by exactly the symmetric states' missing orbit mass).
  • Measured and refused: membership at every candidate bound, three-voter anything, and the four-voter joint-quorum model even under focused Next/Init. One wall seen five ways: the set-valued messages variable. Their configs carry DO NOT WIRE markers with the numbers; the unlock is a message-dimension redesign, documented as future work.
  • Exact state reductions: snapshot create+compact folded into one atomic action (−29–31 % distinct on snapshot-bearing models — the reduction that turned the snapshot obligation from divergent to wired), snapshotPrefix derived instead of stored, 34/34 mutation detectors preserved, identity proven on snapshot-free relations to the state.
  • Operational safety: nightly/weekly no longer collide on Saturdays, a post-restore disk gate refuses continuations that cannot fit a successor generation, five-minute telemetry classifies every continuation's trajectory, and the TLC jar now fetches from the repo-controlled mirror zsumz/tla-tools (upstream v1.8.0 is a rolling channel that deleted two pinned assets in five weeks; the sha256 remains the trust anchor).

Contract

Producer rafter-invariants-tla-v15 → v16, manifest schema 9 → 10, with negative fixtures for every retired identity. primary_completion pins gating-frontier-exhausted for PR (RaftCi still exhausts — 255,177,640 generated / 36,058,645 distinct measured post-reduction, floors tightened to those exact counts) and reporting-continuation for nightly/weekly (a violation or malformed artifact still fails red; only "budget elapsed, frontier healthy" stops being a failure — the verifier rejects any PR receipt claiming reporting mode). Obligations live outside the checkpoint digest, so tuning them never resets primary lineage.

Landing notes

  • Merging this resets the TLC checkpoint lineage once, by design — spec, script, and manifest all feed the spec-bound cache keys. The abandoned frontier was expanding, not converging; nothing nearly-proven is lost.
  • Local verification: cargo test -p rafter-invariants 857 passed, the three CI contract guard targets 84 passed, action pins verified, and every wired floor re-measured on this exact tree.
  • The PR gate run on this PR exercises the new obligations-first path itself; nightly and weekly have been dispatched against this branch ref to prove the scheduled lanes green before any merge.

🤖 Generated with Claude Code

zsumz and others added 19 commits August 11, 2026 09:45
Two scheduled lanes run multi-gigabyte TLC continuations against checkpoints
cached under one repository cache allowance, and nothing kept them apart.

Nightly ran daily at 09:00 UTC; weekly runs Saturday at 07:00 UTC and its TLA+
leg is still going at 09:00. They sit in separate concurrency groups, so on
Saturday both restored, saved, and pruned multi-gigabyte checkpoint
generations concurrently against the same allowance, each evicting the
generation the other had just seeded. Nightly now runs Sunday through Friday.

The second failure is local disk. TLC writes its successor checkpoint beside
the recovered one before retiring it, so peak disk is the restored bytes plus
a full successor plus that successor's growth. Standard runners have roughly
14 GB free; the nightly checkpoint is already 8.14 GB. A continuation started
under that shortfall does not merely fail, it breaks the lineage: no new
generation is saved and the successor the pruning step expects never exists.
scripts/tla-checkpoint-disk-gate measures the restored bytes and free bytes
after restore and fails in seconds unless free >= restored * 1.5 + 4 GiB.

Against today's numbers the gate refuses the run, which is the correct answer:
8.14 GB restored needs 15.37 GiB free and the runner has about 13.

Gate semantics are untouched. An incomplete nightly continuation stays red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A scheduled continuation that ends incomplete says nothing about whether
continuing it converges. The lane reports pass or fail; it does not say
whether the frontier was draining or still growing, and that is the only
number that decides whether this lineage is worth another six hours.

scripts/tla-continuation-telemetry samples the run every five minutes into a
JSONL artifact: TLC's progress counters read from the live stdout capture the
runner already keeps under target/rafter-invariants/telemetry, the queue slope
between samples, frontier fanout (new distinct states per retired state),
checkpoint bytes, free disk, and the TLC process RSS and CPU. On finish it
classifies the trajectory as exhausted, violated, incomplete-expanding,
incomplete-shrinking, or incomplete-flat, and writes that to the step summary.

Fanout is the diagnostic that matters. The nightly lineage currently sits near
2.85 new states per retired state, which names it incomplete-expanding: the
checkpoint is growing because exploration is diverging, not because it is
close to finishing.

This is a side channel by construction. It runs outside the producer, writes
under RUNNER_TEMP so it cannot reach the source-bound evidence tree, and adds
no receipt field, schema, or producer version -- a producer bump would cascade
into contract migrations and the verifier. The terminology is additive and
`summarize` always exits 0: pass/fail stays with the invariant producer, and
an incomplete nightly continuation stays red.

GC statistics are deliberately omitted; jstat would need a JDK attach against
a process this script does not own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nfigs

Split the monolithic proof surface into named obligations. CoreNext,
MembershipNext, SnapshotNext, and ReadNext are strict disjunct-subsets of
ProtocolNext composed from the existing action operators under unmodified
guards, so every behavior of a focused spec is a behavior of Spec. SnapshotNext
is the one subset of Next rather than ProtocolNext, because CompactSnapshot is
reachable only through Next's compaction-first branch, which it reproduces.

JointQuorumInit is a focused initial state for the joint-quorum obligation: the
exact post-state of Timeout(L), three vote round trips, and BecomeLeader(L) from
Init. Every witness and monitor variable is derived from the action that last
writes it along that prefix rather than guessed, so the state is reachable under
Spec and TLC reports no invariant violation on it. Naming a leader makes
ModelPermutations unsound, so JointQuorumPermutations supplies the reduced set
that fixes the leader.

Two manual-run configs carry RaftJointQuorum.cfg's constants and all nine
invariants, changing only the transition relation and initial state, plus the
script flags that run them. Neither is wired to a profile; the profile contract
in crates/rafter-invariants is untouched. Raft.tla is additive only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restricting Next did not make the four-voter joint-quorum model exhaust. The
focused relations are cheaper per state -- MembershipSpec passed the full
model's 45-minute counts in about 14 minutes -- but the frontier grew
monotonically for the whole of both runs, and the extra throughput bought depth
24 against the full model's 20 rather than closure. The focused initial state
did not change that and costs a symmetry quotient to have, since
ModelPermutations was already collapsing the elections it skips.

Records both trajectories, states plainly that the answer is negative, and notes
that the focused-init run was truncated by a full host filesystem rather than by
a budget. Also records what the relations are good for: CoreSpec and ReadSpec
both exhaust at two voters in seconds, so the obligations are usable wherever
the bounds are already tractable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One config per focused relation, each carrying the theorem it states, what it
deliberately excludes, and why each constant narrowing is sound for that
theorem. All four check the nine invariants with CHECK_DEADLOCK FALSE and
SYMMETRY ModelPermutations.

Two are calibrated and exhaust, measured with the pinned tla2tools
2026.08.11.125311 at -workers 4:

  RaftCoreObligation.cfg  20,282 distinct, depth 27, 6s
  RaftReadObligation.cfg  98,948 distinct, depth 31, 26s

Those counts are the ratchet floors for a CI gate that passes only on
states_left = 0.

Both sit at two voters because three does not work. CoreSpec at the nightly
bounds reached 4,210,497 distinct with 2,699,386 queued and depth stalled at
19, and at MaxTerm 2 / MaxLogLen 2 it reached 5,443,386 distinct with
2,557,643 queued at depth 21; both queues grew monotonically throughout. The
node count binds, not the term or log bound, so no reduced-bound three-voter
variant recovers it. ReadNext contains every CoreNext disjunct, so it cannot
exhaust anywhere CoreSpec does not.

The membership and snapshot configs were never run -- the host filesystem fell
below its safety floor first -- and say so in their headers. Their bounds are
reasoned, not measured, and both carry a DO NOT WIRE note until a run reports
states_left = 0. Membership is the harder question: two voters is tractable but
nearly vacuous for quorum intersection, and four voters is already known to
diverge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The TLA+ layer could only ever state one theorem per profile: one config,
checked until it drained or ran out of budget. Anything a profile's bounds
could not reach had no way to be stated at all, so the narrower transition
relations the specification now defines stayed manual-run artifacts with
nothing in the contract holding them.

The runner contract becomes one primary configuration plus a list of focused
proof obligations. Each obligation pins its own config, seed, whole-minute
budget, and calibrated state floors, and passes only by draining its queue
with no invariant violated and both floors cleared. Three rules make that
mean something.

Obligations run before the primary configuration, after harness
qualification. A broken theorem is red in minutes instead of after a
five-hour continuation, and the primary run inherits what is left of the
shared execution window -- which is the budget it was always supposed to
have. The contract refuses an obligation set whose budgets plus the primary
soft_timeout exceed total_timeout less finalization_reserve, so the ordering
cannot silently truncate the monolith at runtime.

Obligations never checkpoint. Each runs from scratch into an ephemeral state
directory, recovers nothing, and participates in no cache key. An obligation
that cannot exhaust in one bounded run is not an obligation but a second
monolith, and belongs in the primary continuation instead. Keeping the list
outside the serialized configuration map is what enforces this: the
checkpoint contract digests only that map, so adding, retuning, or removing
an obligation cannot invalidate accumulated primary TLC state.

Every obligation binds the same nine invariants as the primary config and is
held to the same safety-only boundary, so a config that checks nothing cannot
discharge by exiting cleanly. The negative detector stays bound once per
layer, to the primary config; obligations strengthen the layer and add no
registry evidence rows, so a refutation inside one is reported red as a
harness-level failure for a human to read rather than attached to a predicate
the primary run never falsified.

The verifier reconstructs each obligation independently: its exact TLC argv
from the pinned contract rather than the observed argv, and its terminal
frames from its own authenticated log. Producer and verifier share only
serialized vocabulary. Receipts gain a per-obligation observation frame and
exactly two artifacts per obligation, and the log prefix that fail-fast
ordering produces is checked rather than assumed -- a gap, an out-of-order
log, or a main log after an undischarged obligation is rejected.

The same migration re-pins TLC, because it must. Upstream v1.8.0 is a rolling
nightly channel, not a release: the pinned asset 481553986 was deleted and
now 404s, and three distinct jar digests appeared in five weeks. The new pin
is asset 510140106, sha256 ab323b79..., reporting TLC 2026.08.11.125311. The
asset ID is only a liveness pin; the sha256 is the identity, and a new guard
asserts the checked-in manifest and all three profile pins agree. Because the
tool pin lives in the configuration map the checkpoint contract digests, this
resets the TLC checkpoint lineage once for every profile
(pr 84f4980f->15e3d0ca, nightly 4ca7833d->d1528483, weekly 8d09c275->a14088ec).
Obligations sit outside that map, so future obligation edits do not repeat it.

TLC -coverage was evaluated for this same migration and rejected on measured
evidence: on this repository's own trace-sample model one coverage report
costs about 790 KB of framed stdout (3,849 bytes without, 793,379 with), so
-coverage 5 over the PR tier's 325-minute budget would add roughly 50 MB
against the producer's 64 MiB per-process stdout cap, all of it inside the
hashed, uploaded, and re-parsed tla-log artifact.

No profile declares an obligation yet. All three arrays are empty, and empty
is exactly the identity: receipts, artifact sets, and observation frames stay
byte-identical to the v15 contract apart from the version and tool fields, so
the deterministic PR gate still emits 44 green verdicts. Adding a measured
obligation is a profiles-manifest edit.

Profile schema 9 -> 10 and producer rafter-invariants-tla-v15 -> v16 land
together with negative fixtures rejecting every retired identity. They cannot
be split: the schema version, producer id, tool pin, and obligation
vocabulary validate against each other, so any intermediate commit fails its
own contract tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Snapshot creation set a `compactionPending[n]` flag, `Next` carried a
compaction-first branch that disabled every protocol action while any flag was
set, and `CompactSnapshot(n)` cleared the flag and wrote nothing else. The
intermediate state that sequence produced was unobservable. No protocol action
was enabled in it, the only action that was agreed with its own successor on
every variable except the flag, and no predicate outside the flag's own type
constraint read the flag. It was one extra distinct state per snapshot
creation, plus the branching over which pending node compacts, and no property
could tell it apart from the state that followed it.

The two actions become one, and `compactionPending` goes away entirely --
declarations, `vars`, `snapshotVars`, `TypeOK`, `Init`, every UNCHANGED clause,
and `SnapshotIdentitySoundFor`'s fourth parameter. The folded spec is
stuttering-equivalent to the old one for every property over the remaining
variables: drop the flag from any old behavior and the intermediate state
becomes a stuttering step, which `[][Next]_vars` already admits.

Two names collapse with it. `Next` had a compaction-first branch and
`ProtocolNext` was the disjunction without it; with the branch gone they were
the same relation, so `ProtocolNext` is retired and `Next` is the disjunction.
`SnapshotNext` needed an IF wrapper because `CompactSnapshot` was reachable
only through the branch -- it is now a plain disjunct-subset of `Next` like its
three siblings.

Measured on exhaustive runs, old vs new distinct states: two-voter full `Spec`
at MaxTerm=1/MaxLogLen=1, 35,363 -> 24,995; `SnapshotSpec` at MaxLogLen=2,
305,787 -> 210,043; at MaxLogLen=1, 3,963 -> 2,811. `CoreSpec`, `ReadSpec` and
`MembershipSpec` exclude the snapshot lifecycle and are unchanged to the state:
387/124, 1,405/336 and 7,215/1,708 generated/distinct before and after. That
identity is the evidence the fold touched only what it claims. The detector's
own snapshot lifecycle fixture goes 7 distinct states and depth 7 to 6 and 6 --
exactly the one intermediate state, which is the claim stated as a number.

The mutation suite keeps all 34 tests, but one had to be rewritten rather than
retuned. `snapshot_compaction_pending_tracks_create_and_compact_transitions`
patched `compactionPending' = [... EXCEPT ![n] = TRUE]` to FALSE and the
converse in `CompactSnapshot`; neither string exists now, and the second has no
folded equivalent at all, because atomicity leaves no obligation for a
completing action to discharge. It is renamed
`snapshot_creation_atomically_advances_and_retains_the_snapshot_floor` and
asserts the folded action's two effects instead: a mutation that stops the
snapshot floor advancing stalls the lifecycle and is caught by the same
liveness property as before, and a mutation that compacts the ghost log
physically instead of retaining it breaks `snapshotIndex[n] <= Len(log[n])` and
is caught as an invariant violation. The suite count is unchanged and the
inventory arrays stay sorted.

The membership trace sample executed `CompactSnapshot` as step 38, so that step
is removed and 39..44 renumbered down; the trace is one step shorter and still
executes every transition in REQUIRED_MODEL_TRANSITIONS, which drops from 19 to
18. The trace floors move 46 -> 45 with it, and the reviewed-source digests for
the trace module and the detector replay inventory are re-pinned because the
specs they authenticate changed on purpose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`snapshotPrefix[n]` was a variable holding a copy of the log up to the snapshot
floor, maintained in parallel with `log` and `snapshotIndex`, and
`SnapshotIdentitySoundFor` asserted it equal to `Prefix(log[n],
snapshotIndex[n])` on every state `LogMatching` was checked on. It becomes
`SnapshotPrefix(n) == Prefix(log[n], snapshotIndex[n])`, and the variable goes
away: declarations, `vars`, `snapshotVars`, `TypeOK`, `Init`, `JointQuorumInit`,
every UNCHANGED clause, and the `snapshotPrefixes` parameter of the eight
operators that took a snapshot view alongside a log.

Soundness needs one step beyond "the invariant held". Two recorders are called
with a primed log against an unprimed snapshot floor --
`RecordLogicalPrefixes(log', snapshotIndex, ...)` in `ClientAppend` and
`DeliverAppend` -- where the derived form reads the successor log while the
stored form still held the predecessor's prefix. They agree because no action
rewrites a log at or below its own snapshot floor. `ClientAppend`, `EnterJoint`
and `LeaveJoint` only append. `DeliverAppend` replaces the receiver's log
wholesale, but only under `CanAdoptLog`, which pins every index up to
`commitIndex[n]`, and `snapshotIndex[n] <= commitIndex[n]` holds throughout
because `CreateSnapshot` snapshots at `AppliedThrough(n)` and `InstallSnapshot`
raises `commitIndex` to the transfer index in the same step. That bound is now a
`TypeOK` conjunct, so the argument is machine-checked rather than asserted in a
comment.

One read site is a frozen payload and stays stored. `snapshotTransfer.prefix` is
captured at send time and the sender's log may move before the receiver
installs, so it is a value and not a view; only the expression that computes it
in `TransferSnapshot` changes, from a variable read to `SnapshotPrefix(from)`.
Every other read site was a live view and now goes through the derived operator.

This does not reduce the state count and is not claimed to. The removed variable
was a function of two others, so no two reachable states differed in it alone:
`CoreSpec`, `ReadSpec` and `MembershipSpec` are unchanged to the state at two
and three voters, up to 1,213,423 generated, and so are the snapshot models --
`SnapshotSpec` at `MaxLogLen=2` reports the same 1,312,094/210,043 before and
after. Peak RSS is unchanged within noise (2.90-2.97 GiB over four runs at
`-Xmx8g`, with the two variants' wall times interleaving, so the apparent
wall-time difference was host contention).

What it buys is an obligation retired rather than a cost avoided. Three of
`SnapshotIdentitySoundFor`'s four conjuncts said the stored copy really was the
log's prefix at the floor; they are definitional now, and the one that remains
is the bound that makes the definition well formed. The way to violate them --
writing the wrong prefix -- no longer exists.

The header's correspondence notes gain the two differences this makes explicit:
the model stores no snapshot and models no physical compaction, and creation and
compaction are one atomic action where the implementation has two moments.

All 34 mutation tests still pass. Six of them name the changed operator
signatures as textual patch targets or delimiters and were updated to the new
arities; none changed what it mutates or what it asserts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reductions changed measured state counts that this document reports as
fact, and left two paragraphs describing machinery that no longer exists.

Adds an "Exact state-space reductions" section stating each reduction, its
soundness argument, and an old/new comparison over eight exhaustive models. The
arguments are the load-bearing part: the fold is sound because the intermediate
state was unobservable and the folded spec is stuttering-equivalent over the
remaining variables; the derivation is sound because the equality it replaces
was already checked on every reachable state, plus the one step that needs more
than that -- a recorder called with a primed log against an unprimed snapshot
floor, which agrees because `CanAdoptLog` pins the committed prefix and
`snapshotIndex[n] <= commitIndex[n]`.

The comparison table reports the derivation as changing no state count, because
it does not. Four snapshot-free relations are identical to the state across both
reductions, up to 1.2 million generated states, and that identity is the
evidence the reductions touched only snapshot machinery. Peak RSS was flat
within noise too, so the section says what the derivation actually buys -- an
obligation retired -- rather than a cost saved.

Existing measurement rows are annotated rather than rewritten: they were true of
the spec they were taken on. The two-voter row is marked as no longer
reproducing, with the current figure alongside it. The unexhausted three- and
four-voter rows are left alone, since a reduction does not turn a reading of a
still-growing frontier into a different fact.

Also drops `CompactSnapshot` from the `SnapshotNext` action-family row and
replaces the paragraph explaining why `SnapshotNext` needed a compaction-first
wrapper, since it no longer does.

The `raft_trace_vars_name_every_raft_tla_state_variable` guard asserted the
parsed `vars` tuple had more than 20 members, which was a sanity check on the
parse that happened to sit one below the old count of 22. At 20 it started
failing for the wrong reason. It now guards the parse and says so; the exact
equality against `Raft.tla`'s own tuple, which is the real check, is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The obligations vocabulary landed empty; this fills it with the two theorem
families measurement says can pass a states_left = 0 gate, at floors set to
their exact measured counts.

The pr profile gates on core-replication (RaftCoreObligation.cfg, 113,201
generated / 20,282 distinct, 15s) and read-fencing (RaftReadObligation.cfg,
592,279 / 98,948, 30s), fitting the 11-minute obligation headroom its
325-minute primary leaves. Nightly and weekly gate on read-fencing plus
core-replication-deep (RaftCoreObligationDeep.cfg, new: the same core theorem
at the full three-term, three-slot, two-value nightly bounds -- 14,734,799 /
2,004,053, 8.5 minutes measured), inside their 45-minute headroom.

Floors are exact because TLC's breadth-first counts are deterministic for a
fixed spec, config, and symmetry: a deviation is a spec change and should be
recalibrated deliberately, not absorbed by slack. The two-voter core and read
counts were re-measured on this tree, after the snapshot reductions, and match
the pre-reduction calibration to the state; the deep floor was measured
pre-reduction, and CoreSpec contains no snapshot action -- the reductions'
identity was verified exactly at four other core/read bound-sets.

Membership and snapshot stay unwired, now with measured reasons instead of
missing ones: MembershipSpec at three voters diverges (9,935,434 distinct,
3,959,098 queued and growing at a 30-minute kill), and SnapshotSpec explodes
a two-voter model at MaxLogLen 3 (13,356,326 distinct, 4,992,419 queued,
growing). Their headers carry the numbers, the DO NOT WIRE markers, and the
one unmeasured bound reduction each has left. The pattern across every
divergent run is a single wall: the set-valued messages variable at three
voters, or the snapshot lifecycle at two. Exhausting those obligations is a
message-dimension redesign, not a bounds hunt, and docs/model-checking.md now
says so next to the wired manifest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wiring real proof obligations left thirteen tests behind, because the
synthetic evidence builders described the TLA+ receipt shape as a frozen
list instead of reading it off the contract. Every one of those failures was
the verifier correctly refusing evidence that no longer matched the profile
it claimed to come from.

The shared builders in tests.rs now derive the TLA+ shape from whatever
obligations the manifest declares: two artifacts per obligation, and the four
observations the receipt defines, taken from the same production helper the
producer and verifier use rather than restated. The synthetic terminal frame
is an obligation that drains its queue and clears its own calibrated ratchets
exactly -- the weakest run the contract accepts, so a floor regression cannot
hide behind a generous fixture.

The TLA+ bundle fixture follows: it copies each obligation's configuration
into the fixture checkout so the bound-source comparison has real bytes to
compare, writes a discharged log per obligation, reconstructs the obligation
argv from the pinned contract (its own config, its own seed, the profile's
inherited worker and memory profile), and rebases obligation logs along with
every other TLC process log when the producer root moves. No verifier
acceptance was weakened and no production path learned about test mode.

The round-trip test that asserted the live manifest declares no obligations
now builds a synthetic obligation-free document instead. What it tests is
what the *absence* of the key means, and that property stopped being
observable on the live manifest the moment obligations were wired; asserting
it against real data would only have re-broken on the next edit.

Teaching the fixtures to model the contract then caught a real defect the
empty-array contract could never expose: intake's reviewed artifact-kind
policy admits the detector log and config prefixes but was never taught the
obligation ones, so it classified every obligation artifact as an unreviewed
kind. A receipt carrying a real obligation would have been rejected at
preflight, before verification ever ran. Both prefixes are now reviewed with
semantic bytes retained -- they are text the verifier reads back and
re-parses -- and the policy test pins them so the gap cannot reopen.

The detector-replay inventory failure was unrelated to obligations and is
worth naming separately. Commit dc2ee99 edited
crates/rafter-sim/src/model_check/tests/tla.rs, which is inside the replay
source graph, without re-pinning the reviewed inventory digest. Restoring
that file to its pinned-era content reproduces the old digest exactly, which
identifies the drift precisely; the pin is therefore refreshed to the
inventory the tree actually computes, in both the constant and all three
verifier contracts. The fixture set is unchanged at 77 fixtures, 79 evidence
bindings, and 2 targets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pre-fold spec diverged at two voters and MaxLogLen 3: 13,356,326 distinct
states with 4,992,419 still queued and growing at a 30-minute kill. On the
folded spec at MaxLogLen 2 the same relation drains -- 14,119,884 generated,
2,002,205 distinct, 0 left on queue, under seven minutes -- so the snapshot
lifecycle joins the wired manifest for nightly and weekly at exactly those
floors, inside the 45-minute obligation headroom (25m + 6m + 12m). Atomic
create-and-compact, transfer, install, restart, and application-state loss
are now an exhaustive gating theorem, which is the fold's payoff stated as a
verdict rather than a percentage.

Membership closes the other way. The last candidate bound, three voters at
MaxTerm 2 / MaxLogLen 2 on the folded spec, diverges like every bound before
it: 46,023,230 generated, 7,526,805 distinct, 2,416,633 queued and growing.
Its header now records the complete measurement history and the conclusion:
no exhaustible non-vacuous bound exists under the current message model, and
the honest fix is a message-dimension redesign, not a smaller config.

Fixtures needed no change for the third obligation -- they synthesize
evidence from whatever the manifest declares, which was the point.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Upstream tlaplus/tlaplus publishes v1.8.0 as a rolling nightly channel: the
tag stays fixed while its assets are rebuilt under fresh IDs and the
predecessors deleted. Two successively pinned upstream asset IDs went stale
in five weeks, which makes an upstream asset ID a liveness pin that keeps
breaking, not an identity pin.

The exact reviewed bytes (sha256 ab323b79..., TLC 2026.08.11.125311) now live
at zsumz/tla-tools release tla2tools-2026.08.11.125311, asset 510788686,
whose contract is that published assets are never replaced or deleted. The
fetch URL, ASSET_ID, VERSION, the profile tool_asset_id pins, and the pinned
contract constant all move to the mirror; the sha256 is unchanged because the
bytes are unchanged, and it remains the only trust anchor -- the mirror
solves liveness, the digest solves identity. Fetch verified end-to-end from
a cleared cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Nightly and weekly TLA+ could not go green as gated. The primary monolith
passes only on states_left = 0, and the measured frontier grows at a fanout
near 2.85 with no inflection: it will not drain. Gating on an event that
cannot occur produced a permanently red lane that said nothing about the
protocol, while the evidence those lanes do produce -- proof obligations that
exhaust sound sub-relations, plus accumulated monolith coverage -- went
unreported.

The primary continuation's meaning becomes contract state. A new
`primary_completion` key in the tla configuration map pins
`gating-frontier-exhausted` for pr and `reporting-continuation` for nightly
and weekly, value-checked per profile alongside every other pinned key. It
lives in the map rather than beside it so the checkpoint contract and the
verifier's argv reconstruction read one source of truth.

PR is unchanged in every respect. RaftCi.cfg genuinely drains, so its
continuation still gates on exhaustion at its calibrated floors, and the
verifier refuses outright any PR receipt claiming a reporting continuation --
the one lane that blocks a merge cannot relax itself from inside a receipt.

Reporting relaxes the budget and nothing else. The continuation still runs its
full budget, still checkpoints, still recovers, still emits every artifact,
and is still verified end to end as source-bound evidence. A counterexample it
finds still fails the layer red. So does a malformed, missing, or unreadable
artifact set, an incompatible checkpoint, a failed qualification probe, and an
undischarged obligation. Exactly one outcome stops being red: budget elapsed
with a readable progress frame and an open frontier.

The pinned 120M/16M minimums stay in the contract for the scheduled profiles
as the accumulation bar the lineage reports progress against, published beside
the observed counters instead of enforced as a terminal condition. They are
now pinned per profile rather than shared, so the PR floors are two adjacent
named constants and a RaftCi recalibration is that edit and nothing else.

Receipts state both halves of the truth. Every TLA+ check carries a
`tla_continuation` binding naming its pinned policy and the continuation's
actual ending -- frontier-exhausted, counterexample, or
budget-elapsed-frontier-open -- so a green scheduled receipt cannot be read as
a completed monolith. The verifier rederives the outcome from the same log it
already parsed and rejects a binding that disagrees, and rejects a declared
policy the profile does not pin.

Under a reporting policy the `checked:` observations are sourced from the
obligations rather than the monolith: they bind the same nine invariants and
they did drain. That claim always has a source because the contract now
refuses a reporting profile that declares no obligations -- a profile may only
demote its primary when something else still exhausts.

This folds into v16, which has never run anywhere, rather than minting a v17.
The recorded runner_contract_sha256 table moves with the configuration maps:
pr 84f4980f->a9c75c28, nightly 4ca7833d->931838e2, weekly 8d09c275->724e90e9.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…udit

Two measurement campaigns close the calibration ledger this branch opened.

The post-reduction RaftCi.cfg exhaustion completed: 255,177,640 states
generated, 36,058,645 distinct, queue drained, 93 minutes at -workers 4 on a
14-core host. Those exact counts replace the round 120M/16M floors in the
pinned contract, matching the obligation-floor philosophy -- counts are
deterministic for a fixed spec, config, and symmetry, so the floor is the
measurement, and a deviation is a spec change asking for deliberate
recalibration. The synthetic fixtures now read the floors from the pinned
contract instead of hardcoding cleared values, so the weakest accepted run is
what they model and a future recalibration is a contract edit alone.

Weekly gains its unsymmetrized obligation family: core, read, snapshot, and
an all-feature integration model, each measured to exhaustion without a
symmetry quotient and wired at exact floors. The budget comes from the
reporting continuation -- weekly's primary drops from 265 to 200 minutes, and
the recovered hour funds the symmetry audit applied to every theorem that
gates. The audit is a pair of numbers per config, not a round ratio: each
unsymmetrized count falls short of group-order-times-quotient by exactly the
symmetric states' missing orbit mass (151, 219, 5, and 715 states), which the
config headers and docs now state precisely.

The joint-quorum section's tier-registration objection is retired: the
obligations vocabulary is exactly the mechanism that section said could not
exist. What survives is the measured objection -- it does not exhaust -- and
that one is sufficient.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First CI contact for the branch failed in five places. Four were one defect
wearing four faces, and the fifth was drift between two gates that are
deliberately duplicated.

Artifact kinds are receipt vocabulary and several are structured with a colon.
The detector pair has always kept that colon out of the filesystem: detector
configs and logs are written to explicitly named paths, so the kind stays a
receipt identity and never becomes a filename. Obligation configs went through
the content-addressed capture path instead, which derives the filename from the
kind, and `actions/upload-artifact` rejects a colon outright for Windows
portability -- discarding a completed layer's entire evidence tree after the
work was already paid for. The kind is unchanged; the mapping from kind to
filename now normalizes `:` to `-`, which is the normalization the
source-identity policy already applied when recognizing those same files. A
guard walks every artifact kind the producers can emit, drawn from the live
registries rather than a copied list, and asserts each maps to an uploadable,
non-colliding filename.

The same omission had three more faces that only the serialized end-to-end
fixture could reach, each in a policy that is intentionally owned twice so
producer and verifier cannot weaken each other in one edit. The producer's
source-identity policy and the verifier's generated-output allowlist both had to
learn obligation evidence names, and each derives the rule itself. The
verifier's resource-metric classifier had to learn that obligation logs are TLC
processes: the producer folds their duration and peak RSS into the check totals,
so every receipt that ran an obligation would have disagreed with its own hashed
process logs. That one would have failed the PR gate the moment the upload was
fixed.

The weekly producer refused its own profile at runtime -- "checkpointed weekly
TLA runner requires soft_timeout=265m" -- because the weekly budget moved to
200m in the contract layer while the producer's independent allow-list kept the
old literal. That duplication is the design and stays; what was missing was a
test that runs the reviewed manifest through the producer's gate, so the two
copies can only drift where a test says so. Every earlier test here fed the
allow-list a hand-built map, which is exactly why this reached CI.

Nightly's `snapshot-lifecycle` obligation did not exhaust in its 12m budget on
a hosted runner. It was close: 12,544,244 of 14,119,884 generated states with a
queue that was draining (139,953 -> 91,724 states left) and roughly eighty
seconds of work remaining. The other two obligations landed on their calibrated
counts exactly -- core-replication-deep in 16m26s of 25m, read-fencing in 33s of
6m -- so the calibration is sound and only this budget was set too close to its
own cost. It moves to 25m on both scheduled profiles, paid for by nightly's
primary continuation dropping 265m to 250m. That is the trade the reporting
policy exists to make: the continuation accumulates, the obligations gate, so
budget belongs to whichever one decides the verdict. Weekly already had the
room. A second guard asserts every profile's obligations plus its primary fit
inside its execution window.

Three public enums are documented as deliberately exhaustive. They are
deny-unknown wire vocabularies where an unreviewed value must fail decoding, so
`#[non_exhaustive]` would state the opposite of what they mean.

Also pays the deferred rustfmt drift across the crate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l time

The first nightly dispatch measured CI runners at almost exactly twice the
local calibration wall on the multi-minute models: the deep core obligation
ran 16m26s against 8.5 local minutes and the snapshot obligation 13m24s
against 6.7 -- killed eighty seconds short of a drained queue by a 12-minute
budget set from the local number. Weekly's unsymmetrized snapshot obligation
carried the same mistake at larger scale: 17 local minutes projected to ~34 on
CI against a 25-minute budget.

Every multi-minute obligation budget now carries roughly twice its CI
projection: unsymmetrized snapshot moves to 40 minutes, funded by the weekly
reporting primary dropping from 200 to 190 -- budget belongs to what decides
the verdict. Weekly now runs 110 obligation minutes inside its 120-minute
window; the guards that pin the reviewed manifest against the producer
allow-list and the weekly workflow assertions move in the same commit, which
is exactly the lockstep they were built to force.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant