Skip to content

Lean spec of ccfraft and trace validation - #8382

Draft
cjen1-msft wants to merge 49 commits into
microsoft:mainfrom
cjen1-msft:lean-ccfraft-production
Draft

cjen1-msft wants to merge 49 commits into
microsoft:mainfrom
cjen1-msft:lean-ccfraft-production

Conversation

@cjen1-msft

Copy link
Copy Markdown
Contributor

This is a port of ccfraft.tla over to lean.
I've tried to keep the structure similar to the DR work, and am very willing to align this further, and also change up the model substantially if required. (proof work will need to be repaired unfortunately)

This has

  • Protocol/Model.lean
  • Protocol/Safety.lean - the safety properties on the model
  • Proof/Invaraint.lean - the inductive invariant

Reduction

There is some additional work here that was unnecessary on the other purpose-built protocols.
I tried to align this model with ccfraft.tla (possibly unnecessarily), which means that we have have to deal with non-determinism in the mapping between the model and the code.

For this I'm proposing that we extend the python side to have what I'm calling a reduction step.
As we control both the code and the model, we can use this to deterministically align the 'grains of atomicity' between the two.

This is what that looks like, we have rules in python which reduce a matching set of instructions to a set of actions.

[recv_append_entries, become_follower, send_append_entries_response]
  => [updateTerm, receive]

e.g. reduction.py sees node 1 (stale candidate, term 2) get an AppendEntries at term 4. Because the paired become_follower shows a strictly higher term, it splits the one C++ callback chain into two deterministic canonical actions plus observations:

[
	{"observation": "state", "node": "1",
	 "fields": {"role": "candidate", "currentTerm": 1}},

	{"observation": "message", "source": "0", "destination": "1",
	 "packet": {"msg": "raft_append_entries", "term": 3}},

	{"action": "updateTerm", "source": "0", "destination": "1"},

	{"observation": "state", "node": "1",
	 "fields": {"role": "follower", "currentTerm": 3}},

	{"action": "receive", "source": "0", "destination": "1"},

	{"observation": "state", "node": "1",
	 "fields": {"role": "follower", "currentTerm": 3}},

	{"observation": "message", "source": "1", "destination": "0",
	 "selection": "last",
	 "packet": {"msg": "raft_append_entries_response", "term": 3}}
]

The pipeline I'm proposing for this is:

raft trace -(reduction)-> actions and observations list -(trace validator)-> valid or the failing trace step

The property we get out of trace validation is the alignment between the code and the model.
This alignment is dependent on how well we constrain the model to match the code.
This approach I think should allow us to maximise that alignment and make each step separately auditable.

Comments

SMT trace validation

This trace validation relies on a concrete initial state that we then trace from.
This is not an option for production, as we need to be able to create an initial state from the remote.

We can hack around this by trying to infer what that initial state should be, or producing something 'good-enough', but we will be stuck with a violation trying to work out whether the initial inference was broken or the actual implementation.

Fundamentally what we get from a trace is a set of constraints in the form of observations about each state, and the actions that each node should have taken.
This constitutes a formula that we can then 'just check', and for small traces this works.

The problem is the representation of the trace.
For example supposing that your ledger representation became: fn candidate : predicate(c)
Then to simply evaluate the final state of the trace you have to go via 50-500 of those lambdas.
And tbh SMT trace validation seems to be a mine-field of exactly these kind of issues.

So for example:

  • You observe quadratic scaling in your trace length to checking time.
  • You microbenchmark the current ledger representation, and conclude that it has quadratic scaling.
  • You microbenchmark alternative representations and find one which has linear scaling
  • You update the trace validator
  • You still have quadratic scaling from another component.

Additionally as soon as you do this, your trace validator has drifted from your original model.
In theory, and in practise, you can then prove that the trace validator representation preserves the original properties, and largely this is a mechanical proof, but data-dependent paths in the model make this proof very expensive/difficult.

All of this is to say that this approach works, and can be made to work, but the cost is very high, and this might be a punt for the next generation of models, or improvements in the integration with SMT solvers.
Additionally small prototypes can scale absolutely fine, and then the full thing breaks horribly.

tl;dr

SMT solvers are likely the right approach for this, and the UX works for it. But getting it to scale as it should be possible (this is deterministic, and just filling in details) there are too many hard edges to go down this route yet.

Merging this PR

I'll also split this up further in the future to get it merged. This draft PR is mainly for discussion.

Add a standalone executable Raft model and user-visible safety predicates.
Reduce raw raft_driver captures into deterministic actions and observations
with source provenance and documented callback abstraction boundaries.

Align bootstrap, batching, packet loss, response handling, and retirement
with the implementation. Cover every raft scenario, add negative replay
regressions, and include Lean tooling in repository Python checks and CI.

Keep the safety-proof port separate from this trace-validation checkpoint.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port the existing inductive safety argument without changing the executable
model. Keep the system invariant, ghost histories, and supporting lemmas
under Proofs, with reviewed predicates in Protocol and public statements
in Properties.

Prove reachable election safety, committed-prefix agreement, and signature
commit frontiers. Adapt the argument for physical bootstrap, packet loss,
batched replication, inactive owners, and updated handler metadata.
Exclude independent retirement guarantees and liveness claims.

Require complete library imports and the pinned axiom audit used by the
disaster recovery package. Keep replay executables independent of proofs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extend tla-shallow.yml to install the pinned Lean toolchain, build the
ccfraft-replay executable, and replay every captured Raft scenario
after the existing raft_driver build, uploading trace/diagnostic
artifacts alongside the TLC output.

Document the combined pipeline in the Lean project README.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@heidihoward

Copy link
Copy Markdown
Member

A few initial thoughts:

  • Is this reduction step needed? Can't we just change the model to align with the implementation. For instance, I see that the updateTerm action still exists. I claim we don't need a separate updateTerm action now that we have the flexibility of Lean over TLA+.
  • Can we migrate DR to ExecutableTransitionSystem and make this shared code? anything else we can make shared? some of the trace validation code?
  • Whilst I'm very keen on production tracing, that's out of scope for this PR. We are only aiming to trace validate the same traces as the TLA+
  • Rename spec to consensus, don't call it raft as the protocol long since drifted from raft
  • Move protocol/safety.lean into properties. The properties.lean file should include enough detail on the properties proven that the reader can get a high level understanding of what's been proven
  • I think we should minimize the trace manipulation in python, traces should go (as directly as possible) into Lean. This was needed with TLA+ but should be easier in Lean

@cjen1-msft

Copy link
Copy Markdown
Contributor Author

Heidi Howard (@heidihoward) sorry for the essay...

Can we migrate DR to ExecutableTransitionSystem

Definitely, lots can be shared.

production tracing, that's out of scope for this PR.

Yep thats agreed. I was looking into it as I expect we will not want to drastically change the model after we hit merge. And ensuring we are not wholly locked out of production tracing was important to work out.

minimize the trace manipulation in python

Is this reduction step needed? Can't we just change the model to align with the implementation.

You are correct, we can just align the model with the implementation, I was aiming for something comparable with ccfraft.tla for this model but a more aligned one is also possible.

I'm not sure this is exactly what we want to do as proof repair becomes an expensive concern, say if we add another trace event to raft.h or reorder when things get sent.
Most of the time that should be trivial to repair the proof, but its not going to be a human doing that repair.
But anytime an agent needs to read the entirety of a 50k line proof that is going to cost a reasonable amount.
(Currently its about 5 USD every time Astra reads the proof, with Sol at around 2 USD)

We have two somewhat orthogonal aims here

  • Extracting model constraints from the trace which needs to be audited no matter what
  • Discovering an example of model steps which matches the constraints, or proving that none could.

The discovery requires that we either use some search function to discover this, which becomes intractable quickly (infinite models etc), or SMT scaling problems.
Or we have a mapping from trace actions to model steps, with full alignment being a limiting case of this, and we audit this / AI repair this.

The reduction approach is just a way to represent that mapping.

I don't fully see the tradeoff between python and lean as the host for the mapping.
One possible benefit would be if we could have a meta-properties over the mapping like 'The committed ledger prefixes map in this way and this is ensured over all mappings' which could allow you to better align the safety properties with the implementation.

@cjen1-msft

Copy link
Copy Markdown
Contributor Author

Also to capture a thought.
We currently have multiple trace events which map to one big atomic event.

This can be modelled either as N -> 1 reduction.
Or it can be modelled as 1->1.

The issue is that the 1->1 model must reconstruct the serial execution of the raft.h, via sequencing or phases.

Chris Jensen (Cjen1) and others added 19 commits September 18, 2026 12:15
Split the global-state model into a node-local step (Model/Node.lean,
Model/Local.lean) composed by the shared MultiNodeTransitionSystem, copied
from the disaster-recovery package. A delivery now adopts a newer message
term and handles the message in one step, replacing UpdateTerm. Messages
lose their source and destination fields, which the envelope carries.
Global join and transaction-identifier histories are gone.

The replayer resolves each receive to the oldest undropped envelope and
records drops without stepping the model. The reduction emits one receive
per packet. The old global model and its invariant proofs move to
Proofs/Abstract, sharing the node-level definitions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Proofs/Refinement relates each reachable state of the network model to an
abstract global-state model that satisfies the existing inductive invariant.
A concrete step is simulated by reordering one abstract queue, an abstract
term update when the receiver adopts a newer term, an abstract same-term
step-down, and the abstract action with the same name. The abstract model
drops its join-once and unique-transaction-identifier guards, which the
invariant proofs never used.

Properties.lean states election safety, committed-log prefix agreement,
signature commit frontiers, and append-only committed logs over valid
traces. Proof.lean links each claim, and Witnesses.lean gives a concrete
kernel-checked trace satisfying the premises of each.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Describe the node-local model, the network composition, the properties,
and the refinement proof in the package README. Bump the replay schema to
ccfraft-replay/v3, because a receive now adopts a newer term itself and v2
documents contain separate updateTerm actions. Format the model with the
pinned leanfmt, and build the whole package with --wfail in CI.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Apply the pinned leanfmt at its default line width to every tracked Lean
file in lean/ccf-raft, matching the repository Lean formatting check.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…tion

# Conflicts:
#	.github/skills/formatting-and-linting/SKILL.md
leanfmt loads each file's imports, so the CCF Raft files need that
package's environment. Check each package from its own directory, keep
files outside lean/ with the disaster recovery package, and run the check
for its own package in each Lean CI job.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add NodeInvariant, which lifts a predicate that every local step preserves
to every node of every reachable state, and use it to prove
CommittedFrontierIsSignature without the abstract model. Move the
concrete-model facts the refinement shared into Refinement/Concrete.lean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A local step never lowers the commit index and never rewrites the log at
or below it, given the commit frontier invariant. A network step changes
only the acting node, so the property needs no global reasoning.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the same-state CommittedLogsPrefix and the per-step
CommittedLogAppendOnly claims with one property: any two committed logs in
a valid trace, from any nodes in any states, are prefix-comparable. The
proof carries the earlier node's committed log forward, where it has only
grown, and compares the two logs in the later state. The append-only step
lemma and same-state agreement remain as proof lemmas.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…model

Proofs/Abstract and Proofs/Refinement are removed. Proofs/Invariant/View.lean
computes the global view of a network state that the invariant reads, and
Proofs/Invariant/Facts.lean restates the old invariant definitions over that
view. Proof.lean and Proofs/CommittedLogs.lean do not build yet: the
property derivations and the step preservation proofs are still to port.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ed invariant

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…View

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ervation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…mit advancement

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…votes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
cjen1-msft and others added 22 commits September 24, 2026 00:22
…ed endpoints

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Prove reachable_inv by induction over concrete local and delivery steps. Term observation, same-term step-down, message erasure, and replies preserve the invariant without a second transition system.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove 71 unused migrated lemmas and the unused message-source guard. Add a kernel dependency report for future proof cleanup, and document the concrete-step invariant argument.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Use endpoint/payload tuples for immutable message histories and quantify the joined set separately. Facts builds independently; preservation consumers are migrated in the following units.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove copied request handlers and metadata-erasure adapters from proof operations. Prove local handler facts against Model.Local and frame configuration coverage through concrete node lookup.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Use concrete node-list replacements, envelope sends, and joined-set side conditions. The replacement StateFacts and Effects modules compile independently.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Keep joined-set growth separate from node-list updates. Characterize immediate acknowledgement through the actual follower acceptance handler, without clearing retirement metadata.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Characterize ACK eligibility using the concrete acceptance handler. Replace queue updates with removal from the envelope list and preserve transfer to processed match indices.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The full package builds with warnings as errors, and the axiom audit permits only the existing standard axioms.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Retain the node-lookup network simp rule used during elaboration. Remove seventeen declarations outside both exported proof dependencies and explicit tactic references.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Carry node-table distinctness in StateInvariant and use it directly in safety projections. Keep the joined set as existential ghost state and describe endpoint/payload history keys.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Amaury Chamayou (achamayou) added a commit that referenced this pull request Sep 28, 2026
Consume the RDP_TRACE format of #8366 with the reduction and replay design
of #8382, replacing the separate disaster-recovery-trace validator package.

- replay/reduction.py reduces the node logs of one e2e scenario to
  disaster-recovery-replay/v1 actions and observations. Records are ordered
  by each node's sequence, receives are linked to the send their caused_by
  names, and each retry batch is one model retry. Handler records are
  speculative, so the reduction infers which executions took effect from
  later records and committed records: superseded re-executions and stale
  reads did not, a vote read while Gossiping may have committed before an
  earlier transition, and the newest phase writes may stay unconfirmed.
  Every instruction names its rule and log record. Trace failures, receives
  without caused_by, and records that no account explains fail the run.
- DisasterRecovery/Replay.lean replays the document through
  Model.transitionSystem. Actions must be enabled, and observations must
  match the model's node state or the messages and notifications of the
  node's latest action. replay/ReplayMain.lean is the
  disaster-recovery-replay executable.
- replay/validate_logs.py waits for complete logs, then reduces and replays
  them, keeping the artifacts. tests/infra/recovery_trace.py only passes it
  the log paths and scenario expectations.
- Python tests in replay/tests cover the rules, including re-executions
  sharing caused_by, unconfirmed final executions, receives before the first
  committed record, repeated restarts in Joining, and rejection of missing
  caused_by and trace failures. The Lean workflow runs them. The Genoa SNP
  job builds the replayer and uploads each scenario's replay artifacts.
- As in #8382, the Python format and lint checks now cover lean/.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Amaury Chamayou (achamayou) added a commit that referenced this pull request Sep 28, 2026
ruff EXE001 requires files with a shebang to be executable, as #8382's are.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Amaury Chamayou (achamayou) added a commit that referenced this pull request Sep 29, 2026
Consume the RDP_TRACE format of #8366 with the reduction and replay design
of #8382, replacing the separate disaster-recovery-trace validator package.

- replay/reduction.py reduces the node logs of one e2e scenario to
  disaster-recovery-replay/v1 actions and observations. Records are ordered
  by each node's sequence, receives are linked to the send their caused_by
  names, and each retry batch is one model retry. Handler records are
  speculative, so the reduction infers which executions took effect from
  later records and committed records: superseded re-executions and stale
  reads did not, a vote read while Gossiping may have committed before an
  earlier transition, and the newest phase writes may stay unconfirmed.
  Every instruction names its rule and log record. Trace failures, receives
  without caused_by, and records that no account explains fail the run.
- DisasterRecovery/Replay.lean replays the document through
  Model.transitionSystem. Actions must be enabled, and observations must
  match the model's node state or the messages and notifications of the
  node's latest action. replay/ReplayMain.lean is the
  disaster-recovery-replay executable.
- replay/validate_logs.py waits for complete logs, then reduces and replays
  them, keeping the artifacts. tests/infra/recovery_trace.py only passes it
  the log paths and scenario expectations.
- Python tests in replay/tests cover the rules, including re-executions
  sharing caused_by, unconfirmed final executions, receives before the first
  committed record, repeated restarts in Joining, and rejection of missing
  caused_by and trace failures. The Lean workflow runs them. The Genoa SNP
  job builds the replayer and uploads each scenario's replay artifacts.
- As in #8382, the Python format and lint checks now cover lean/.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Amaury Chamayou (achamayou) added a commit that referenced this pull request Sep 29, 2026
ruff EXE001 requires files with a shebang to be executable, as #8382's are.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

This branch has not been deployed

No deployments
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.

3 participants