Skip to content

raft: trace validation of the shell against Raft.tla (tla-trace) - #27

Open
kiranandcode wants to merge 8 commits into
yl/raft-safety-refinefrom
kg/tlc-conform
Open

kiranandcode wants to merge 8 commits into
yl/raft-safety-refinefrom
kg/tlc-conform

Conversation

@kiranandcode

@kiranandcode kiranandcode commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

Implements the toyDB half of plans/tlc_conform.md: trace validation of the Raft shell. With --features tla-trace, the shell logs every safety-model transition it performs. TLC then checks each log against the model, here the hand-written tla/Raft.tla oracle (the exported Raft module is being produced separately).

Based on yl/raft-safety-refine (#16), which carries tla/Raft.tla (#25).

What was built

  • tla-trace/ is a small, dependency-free crate. It writes the per-thread log: a header, then one JSON line per step, in the Verus exporter's value encoding (Value, start/finish). trace_step! logs a step and evaluates nothing when no log is open.
  • Emission is behind the tla-trace feature, with no effect without it.
    • After each call of a verified step function, the shell (node.rs) logs the t_* transitions that call performed, with the model's parameters (ranks, terms, indices), through a local trace_step!.
    • Each line carries the stepping node's observed host: term, vote, role, log, commit, plus votes while a candidate and read_seq while a leader. It is read by a feature-gated view of Abs in refine.rs, placed after the verus! block; verified code is untouched.
    • A step function that performs several transitions logs each one. A follower heartbeat logs ack, read confirmation, then commit, the earlier lines without the commit that follows them.
    • A leader commit also logs the ack map q it rests on, which the leader knows from its match indexes. Otherwise TLC would branch over every ghost q.
    • Node::new on a used log is t_restart.
  • The node goldenscripts each write target/tla-traces/node/<script>.ndjson when built with the feature. The header holds the model's initial hosts.
  • tla/Raft_trace.tla is the oracle's trace spec. It has the same interface as the exporter's generated one (TraceLog, one VARIABLE, TraceInit, TraceNext, TraceEnabled, TraceDiagnosis), so verus-tools-mcp's tlc_conform runs either. tla/Raft_trace.cfg configures it, and tla/conform.sh runs every script's log through TLC.

Result: all 58 node goldenscripts conform

Each script conforms with exactly one explaining behaviour (distinct states = steps + 1). The scripts range from 0 to 248 steps, on 1 to 7 nodes.

The first run found one divergence, in the test harness, not the shell. cluster ... leader=N promoted its leader by calling collect_vote for every peer directly, with no Vote message behind the votes. 41 of the 58 scripts are built that way. The first run checked 47 scripts before my survey script crashed; 35 of those were built that way, and all 35 left the model at step 2, t_become_leader: Raft.tla's t_collect_vote needs the Vote in the network, so the leader's vote set was {0}. The harness now elects through the message path (campaign, then stabilize). Every goldenscript's output is unchanged.

A second issue was in my own harness code: a one-node cluster elects itself inside Node::new, so its log must open before the nodes exist.

Deviations from the plan

  • trace_step! is called at the step-function call sites in the shell, not inside the verified step functions.
    • Several step functions hold only &Log, and reading the log's entries needs &mut.
    • An external_body taking &mut Log would havoc the log in the proofs.
    • The shell sees each step function's result, so it knows which transitions ran.
  • Raft_trace.tla does not conjoin Next again, unlike the generated trace spec. Each TraceStep arm is t_x(i) for a node i, which is a disjunct of Next. Re-evaluating all of Next made TLC enumerate t_leader_commit's ack maps [Q -> 0..MaxLog] at every step, past a million functions at N = 5. For the same reason, t_leader_commit is taken at its logged witness (Q, q) (LeaderCommitAt, its body with the existentials instantiated).
  • The log's commands are only noop-or-not; Raft.tla abstracts commands to Command (A2), and the trace runs with Command = {c1}.
  • Plan step 3 is checked against the hand-written oracle, as instructed, not the export. Once the Raft export is faithful, TOYDB_TLA_TRACE_MODULE renames the header's module and the same logs apply. One caveat: the generated trace spec needs every step parameter logged or finitely bounded, and the shell cannot log ghost payloads such as clog/vlog/rec.

Test plan

  • cargo test (without the feature): every raft goldenscript passes, output unchanged by the harness election change.
  • cargo test --features tla-trace --lib raft::node::tests writes 58 logs.
  • TLA2TOOLS_JAR=<fork jar> tla/conform.sh: all 58 conform, exit 0.
  • Through the MCP server (BasisResearch/verus-tools-mcp#59), tlc_open tla/Raft.tla then tlc_conform on election.ndjson with constants: ["N = 3", "MaxTerm = 20", "MaxLog = 50", "MaxRead = 50", "Command = {c1}"]: conforms (20 steps, 21 states). A copy logging ci = 2 for a one-entry leader diverges at step 15: the step is not enabled, and the model state and enabled steps are reported.
  • cargo fmt --check; cargo clippy --tests --no-deps -- -D warnings, with and without --features tla-trace; cargo doc --no-deps; tla-trace crate tests and clippy.
  • t_recv_append is pinned to the logged Append (term, base, base term) and to the ack built from the logged match_index (RecvAppendAt). All 58 scripts still conform.
  • CI: the Test job runs clippy and the tests with --features tla-trace, plus fmt, clippy and the tests of the tla-trace crate. A new Trace validation job downloads the pinned basis-11305b4a05 jar (sha256-checked) and runs tla/conform.sh over every node goldenscript's log (all 58 must conform), then tla/conform.sh tla/traces/*.ndjson over the two known-bad fixtures. election-bad-quorum must diverge at step 15 (a commit on a non-quorum ack map). election-bad-ack must diverge at step 10 (a follower acking past the Append it applied); the unpinned spec accepted this one.
  • Verus gate: left to CI (no released cargo-verus on this machine). The verus! block is unchanged; the new refine.rs code is a feature-gated module after it.

Companion PRs: BasisResearch/verus#54 (the exporter's trace spec) and BasisResearch/verus-tools-mcp#59 (tlc_conform).

🤖 Generated with Claude Code

kiranandcode and others added 8 commits September 27, 2026 01:33
…dation

One JSON line per model step ({step, params, state}) after a header
naming the module, in the Verus exporter's value encoding, written to a
per-thread log opened by start() and closed by finish(); trace_step!
logs a step, and evaluates nothing when no log is open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each call of a verified step function in the shell (node.rs) logs the
safety model transitions it performs with trace_step!: the t_* name,
the model's parameters (ranks, terms, indices, and the ack map q a
leader commits on), and the stepping node's observed host state after it
(term, vote, role, log, commit; votes while a candidate, read_seq while
a leader), read by a feature-gated view of Abs outside verus!. Where one
step function performs several transitions (a follower heartbeat: ack,
read confirmation, commit), each is logged, the earlier ones without
the commit they precede. A restart (Node::new on a used log) is
t_restart. With --features tla-trace, every node goldenscript writes its
log to target/tla-traces/node/<script>.ndjson. Without the feature
nothing changes; verified code is untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cluster leader=N recorded the peers' votes directly with collect_vote,
so no Vote message stood behind them: a step log of any such script
leaves the safety model at t_become_leader (Raft.tla's t_collect_vote
needs the Vote in the network). The harness now campaigns and
stabilizes instead, as a real election would; every goldenscript's
output is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A one-node cluster elects itself as its node starts, so a log opened
after the nodes were added began at a leader. The header now holds the
model's initial hosts and the log sees the self-election.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t Raft.tla

The hand-written counterpart of the exporter's trace spec, with the
same interface (TraceLog, one VARIABLE, TraceInit, TraceNext,
TraceEnabled, TraceDiagnosis), so tlc_conform runs either. TraceNext
takes Raft.tla's action for the logged node, pins what it binds from the
network with the other logged parameters (the Append, ack or read
confirmation added, the ack map a commit rests on), and compares the
observed host in the exporter's encoding (votes as ranks or Nil, roles
as strings, commands as Nil or some element of Command). Each arm is a
disjunct of Next, so Next is not conjoined again: that would make TLC
enumerate t_leader_commit's ack maps at every step.

tla/conform.sh runs the node goldenscripts with the tla-trace feature
and checks every log with TLC: all 58 conform, each with exactly one
explaining behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The shell now logs the follower's match_index (mi) with t_recv_append, and
Raft_trace.tla takes the step as RecvAppendAt: t_recv_append's body with
the message's term, base and base term pinned to the logged ones and the
ack to MAck(i, term, mi). Before, the arm pinned only the term, so TLC
could explain the step by any Append in net and add the ack that one
implied, which a later t_leader_commit's q could then rest on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Test job now runs clippy and the tests with --features tla-trace, and
fmt, clippy and the tests of the tla-trace crate (not a workspace member).
A new Trace validation job downloads the pinned BasisResearch/tlaplus jar
(sha256-checked) and runs tla/conform.sh on the election script's log and
two known-bad fixtures in tla/traces/, which must diverge at the step
their header's "expect_divergence" names: a commit on a non-quorum ack
map (step 15) and a follower acking past the Append it applied (step 10;
the previous trace spec accepted it).

conform.sh takes log paths as well as script names, runs the tests only
when a script log is wanted (clearing stale logs first), and fails when
TLC reports no search depth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eartbeat ack sent

The Trace validation job ran conform.sh on election alone, leaving the
other 57 scripts' conformance unchecked. It now runs conform.sh over
every node goldenscript's log, then over tla/traces/*.ndjson.

The follower heartbeat path logged t_send_ack with the heartbeat's
last_index; it now logs plan.match_index, the value the
HeartbeatResponse carries (equal when non-zero, by follower_heartbeat's
postcondition).

Co-Authored-By: Claude Opus 5.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