docs/trusted-computing-base.md ends on the same sentence every time: lean/ proves a conditional,
and everything to the left of its "if" is fifty thousand lines of unverified Rust writing down the
truth. The functions that decide whether it does are small, pure and total. This page is the record
of pointing a Rust-to-Lean verification tool at exactly those functions and finding out, honestly,
what it can and cannot do here.
The tool is Aeneas (Inria and Microsoft Research). Its
front end, Charon, is a rustc driver that extracts LLBC
(a low-level borrow calculus) from a crate; Aeneas consumes the LLBC and emits a pure functional
model in Lean 4, F*, Coq or HOL4. It handles a subset of safe Rust.
Read the gate first. It decides everything else on this page.
Three questions had to be answered before a line was written, and two of them have answers that constrain the result permanently.
Yes. macOS 26.6.2 (darwin 25.6.0), Apple silicon. Aeneas publishes a nightly release per day with
a aeneas-macos-aarch64.tar.gz asset carrying prebuilt aeneas (OCaml), charon and
charon-driver (Rust) binaries plus the four backends, so no OCaml toolchain was needed. This
machine has no opam and none was installed. The pinned release is
nightly-2026.09.15-505b6ca (505b6ca35217e7be5c96c3e2f8045edfbdf47291).
aeneas/run.sh fetches it, runs Charon over aeneas/oo-boundary, runs Aeneas over the resulting
LLBC and writes aeneas/lean/OOBoundary/Generated.lean. End to end it takes under ten seconds once
the release is downloaded.
This is the finding that shapes everything else. It does not break the three-axiom pin, and
the measurement further down says so; what it breaks is any hope that the output could live in
lean/.
lean/ is core Lean only. lean/lakefile.toml says so in a comment and the manifest is empty: no
Mathlib, no Batteries, no external package at all, leanprover/lean4:v4.33.1, and every headline
theorem carries a #guard_msgs-checked #print axioms pinning it to exactly
[propext, Classical.choice, Quot.sound].
Aeneas's generated files open with import Aeneas. That is not optional and there is no smaller
target: the model is written in Aeneas's Result monad over its Slice, alloc.vec.Vec and
UScalar types, and every one of those lives in the support library. The support library's
backends/lean/lakefile.lean reads, verbatim:
-- Important: mathlib imports std4 and quote4: we mustn't add a `require std4` line
require mathlib from git
"https://github.com/leanprover-community/mathlib4.git" @ "v4.31.0"and its backends/lean/lean-toolchain reads leanprover/lean4:v4.31.0.
So, concretely, measured in this repository rather than quoted from a README:
lean/ |
aeneas/lean/ |
|
|---|---|---|
| Lean | v4.33.1 |
v4.31.0 |
| Lake packages required | 0 | 10 (aeneas, mathlib, batteries, aesop, Qq, plausible, proofwidgets, importGraph, LeanSearchClient, Cli) |
.lake on disk after a build |
78 MB | 7.6 GB, of which 6.2 GB is Mathlib (8176 .oleans, fetched by lake exe cache get) |
| axiom pin | [propext, Classical.choice, Quot.sound], no exceptions |
see "the axiom accounting" below |
Some care is owed here, because "depends on Mathlib" is not by itself an indictment:
- Mathlib does not add axioms. Mathlib's own policy is the same three, so importing it does not by itself put a fourth axiom behind a theorem. The axiom accounting below is measured, not assumed.
- Aeneas's
Stdissorry-free.grep -rn sorry backends/lean/Aeneas/Std/is empty. The ninesorrys in the bundle are all inside#guard_msgsdoc-strings inAeneas/Tactic/, which are the expected output ofextract_goalin the tactic framework's own tests, not sorried declarations.native_decideappears only inexamples, which do not propagate.
What is true is that the trust surface is a different object. lean/'s is "the Lean kernel plus
what is written in this directory, small enough to read in an afternoon". aeneas/lean/'s is the
Lean kernel plus Mathlib plus eight more packages plus the Aeneas support library plus the Aeneas
translation itself. The three-axiom pin is worth having precisely because it has no exceptions, so
this work does not touch it: aeneas/lean/ is a separate Lake workspace with its own
lean-toolchain, its own manifest and its own lake build, and there is no path from
lean/lakefile.toml to it. Nothing in make check or lake build under lean/ reaches this
directory.
Not with the shipped crate, and it does not need to. Charon is a rustc driver linking
rustc_private, so it pins a nightly exactly: nightly-2026-08-18 with rustc-dev, llvm-tools
and rust-src. The shipped crate has no rust-toolchain file, is edition = "2024" and builds on
stable (1.96.0 here). It also has around ninety dependencies and fifty thousand lines, most of it
reaching into oxrdf, oxiri, tokio and axum, none of which Aeneas models.
So Charon is pointed at aeneas/oo-boundary, a crate with one line of its own:
#[path = "../../../src/boundary_core.rs"]
pub mod boundary_core;and no dependencies. The rust-toolchain pin lives in that directory and nowhere else. This is the
only place in the repository that names a toolchain, and the shipped crate still builds on stable.
src/boundary_core.rs is new and is the pure core of the certificate boundary: seven functions and
one three-constructor enum, over u8, &[u8], Vec<u8>, usize and bool. src/reason.rs and
src/tableaux.rs call it. It is not a copy of them and there is no transcription step: the crate
Charon reads reaches this same file with #[path], so the Lean model is a model of the bytes the
shipped engine compiles.
Six shipped functions became one-line wrappers:
| shipped | file | now |
|---|---|---|
writable_triple |
src/reason.rs |
writable_triple_bytes(s.as_bytes(), p.as_bytes()) |
field_fits_the_format |
src/reason.rs |
field_fits_the_format(f.as_bytes()) |
term_fits_the_format |
src/reason.rs |
term_fits_the_format(t.as_bytes()) |
push_asserted_line |
src/reason.rs |
push_asserted_line_bytes(out, …) |
push_triple_fields |
src/reason.rs |
push_triple_fields_bytes(out, …) |
name_is_safe |
src/tableaux.rs |
name_is_safe_bytes(s.as_bytes()) |
The refactor was not free, and neither cost is cosmetic:
- The certificate buffers are now
Vec<u8>rather thanString. Aeneas does not modelstrwell enough to translate a writer that appends into one, and a wrapper cannot bridge that withoutunsafe.std::fs::writetakesAsRef<[u8]>, so nothing downstream notices, and every byte appended still comes from a&str. The two property tests and the two generic Kani harness bodies that assert on the buffer (nine instantiations between them) were changed to match; what they assert is unchanged, andmake verifystill verifies all fifteen. &strpredicates became byte predicates.s.contains([' ', '\t', '\n', '\r'])became a byte scan. These agree on every&str, because all four are ASCII and no ASCII byte occurs inside a multi-byte UTF-8 sequence, but that sentence is an argument and arguments are what this repository writes tests for.tcb_wrappers_agree_with_the_char_level_predicatesinsrc/reason.rsis that test: it runs the originalchar-level bodies and the new byte-level ones over the same adversarial generator and requires them to agree.
Everything in src/boundary_core.rs, with no opaque declarations and no sorry:
Position (with its derived Clone, Copy, Debug, PartialEq, Eq instances),
starts_with_byte, writable_triple_bytes, field_fits_the_format, term_fits_the_format,
name_is_safe_bytes, push_asserted_line_bytes, push_triple_fields_bytes. 311 lines of Lean.
The parts most likely to have failed did not, and they are worth naming:
- The
whileloops translated. Each becomes abodyreturningControlFlowplus aloopcombinator defined bypartial_fixpoint, which is a real fixpoint and not an axiom. The induction to reason about it has to be written by hand; it is written inaeneas/lean/OOBoundary/Proofs.lean. Vec::extend_from_slicetranslated to a primitive with a real model (alloc.vec.Vec.extend_from_slice, which isv.val ++ s.valunder a length side-condition), not to an opaque declaration. That is what let the writers keepextend_from_sliceinstead of a byte-at-a-time push loop, so the refactor costs no memcpy.
See aeneas/lean/OOBoundary/Proofs.lean. The decision procedures are stated as equations
model x = ok (spec x) rather than as Hoare triples, because an equation covers more ground than a
triple: the model never fails, it never diverges, and its result is the specification.
Fourteen theorems, in OOBoundary:
| theorem | says | was |
|---|---|---|
starts_with_byte_eq |
the leading-byte test is the head of the list, every length | not stated |
writable_triple_bytes_eq |
the unwritable-conclusion guard decides both positions, every length | BOUNDED: writable_triple_decides_both_positions fixes both terms at four bytes |
field_fits_the_format_eq / _iff |
a field is accepted IF AND ONLY IF it is non-empty and carries no tab, line feed or carriage return | SAMPLED |
name_is_safe_bytes_eq / _iff |
TCB-25: a name is accepted IF AND ONLY IF it is non-empty and carries no space, tab, line feed or carriage return | SAMPLED: tcb_25_name_is_safe_refuses_every_separator |
term_fits_the_format_eq |
TCB-4 and TCB-5: accepted exactly when non-empty, separator-free and in one of the three N-Triples spellings | SAMPLED |
push_asserted_line_bytes_ok |
when the three terms fit, the buffer gains exactly s TAB p TAB o LF |
BOUNDED: asserted_line_round_trips_at_0 to _at_4, so terms of zero to four bytes |
push_triple_fields_bytes_ok |
the same for TAB s TAB p TAB o |
BOUNDED: triple_fields_append_exactly_three_at_0, _at_2, _at_3, _at_4 |
push_asserted_line_bytes_refuses / push_triple_fields_bytes_refuses |
all or nothing: a term that does not fit makes the writer refuse AND hand back the buffer it was given, byte for byte | BOUNDED, and only at a one-byte prefix |
asserted_line_reads_back |
TCB-1: an accepted line is one record on line feed, and that record splits on TAB into exactly the three terms that went in | SAMPLED |
triple_fields_adds_exactly_three_fields |
TCB-2, TCB-3: a line carrying n tab-separated fields carries exactly n + 3 afterwards, FOR EVERY PREFIX |
BOUNDED: prefix fixed at one byte |
triple_fields_are_the_three_terms |
and which fields they are, in order | SAMPLED |
One hypothesis, said out loud because "no bound" should not be read as "no
precondition": the two _ok theorems assume the buffer plus the line it is about
to gain fits in a usize
(out.length + s.length + p.length + o.length + 2 < Usize.max). That is not a
length bound on the terms, it is the capacity precondition Aeneas's Vec model
carries and that Rust's Vec enforces by aborting. The two _refuses theorems
carry no such hypothesis, because refusing allocates nothing.
The entries a bounded checker cannot reach at all are worth reading twice:
- The prefix in
triple_fields_adds_exactly_three_fieldsis unconstrained and may itself carry tabs. The Kani version fixes it at a single byte. Real prefixes are a rule name, or a rule index and a binding list of any length, and then the conclusion and premises of a step already written. The Lean theorem covers every one of them because it is proved fromsplitOnByte_append_cons, which does not assume the left side is separator-free. writable_triple_bytes_eqcovers the EMPTY term and the Kani harness cannot: it allocates a fixed four-byte array. What the theorem shows is that an empty subject PASSES this guard, which is fine only becauseterm_fits_the_formatrefuses an empty term and the certificate paths run both. That is now written down rather than assumed.
The reader-side theorems are stated against Spec.splitOnByte, a one-byte
splitter written out in aeneas/lean/OOBoundary/Spec.lean rather than imported,
so that anyone checking whether the statement says what they think it says can
read the splitter in ten lines. It is not OOCert.Parse's splitter, and the
claim is the one docs/trusted-computing-base.md already argues is the right
one: the theorems are about the BYTE LAYOUT, which is what any correct splitter
needs and all it needs.
strandString. Aeneas has aStringmodel but not enough of one to translate a writer that appends into aString, which is why the certificate buffers moved toVec<u8>and the predicates moved to&[u8]. Everything else on this page follows from that one limitation.- The interner (TCB-15, TCB-16, TCB-17).
Interneris aHashMap<String, u32>plus aVec<String>. Aeneas does not modelstd::collections::HashMap, and the property at issue (that interning is a bijection) is about the map. Nothing here touches it. parse_pat(TCB-20). Returnsanyhow::Resultand formats a message naming the line and the position, so translating it would mean translatingcore::fmtandanyhow. This is the same shape of obstacle that killed its Kani harness, and the same fix would serve both: lift the classification into a pureOption<Pat>function and leave the messages where they are. That fix was made, and it did not help here.pat_ofis that function, it landed on 15 September 2026, and Kani verifies it. Aeneas still cannot take it, for the unrelated reason at the top of this list:pat_ofreturnsOption<Pat>andPatcarries aString, so the obstacle is the string model rather than the error formatting. Moving it into the byte discipline the rest of this module uses would meanPatoverVec<u8>, which changes the type every rule-table call site names. That was not done, and TCB-20's whole-line half is consequently the one serialisation property ondocs/trusted-computing-base.mdthat still has no unbounded proof about this Rust. Dafny proves it unbounded about a REIMPLEMENTATION, which is a different claim; decision 0014 measures what that is worth.- Nothing was silently dropped. The generated file has no
opaquedeclaration, noaxiomand nosorry:grep -n "opaque\|axiom\|sorry" aeneas/lean/OOBoundary/Generated.leanis empty. Aeneas reports eight opaque functions during the run; they arecorelibrary functions it models inAeneas.Std(theDebugderive reachescore::fmt::Formatter::write_str), they are not emitted into the file, and no theorem here depends on one.
Trading "fifty thousand lines of unverified Rust" for "Aeneas's translation from Rust to the Lean
model is faithful" is a large net win at the boundary, and it is still a trade. TCB-30 in
docs/trusted-computing-base.md records it.
All fourteen theorems pinned in aeneas/lean/OOBoundary/Axioms.lean depend on exactly
[propext, Classical.choice, Quot.sound]
which is the same three lean/ pins. aeneas/lean/OOBoundary/Axioms.lean
checks every one of them with #guard_msgs, so a change breaks lake build in
this directory rather than widening the trust surface quietly. Measured, not
assumed: the first draft of this page said "Mathlib does not add axioms" as a
fact about Mathlib's policy, and a policy is not a measurement.
Do not read that as "so it is the same regime." It is not, and the difference is not in the axiom list:
- the Lean is
v4.31.0againstlean/'sv4.33.1, so the two can never share a file, a build or alake buildinvocation; - checking any of it needs ten packages on disk and a 7.6 GB
.lake, 6.2 GB of it Mathlib.oleans, against 78 MB forlean/, which needs a Lean toolchain and nothing else; - the elaborated proofs run through Mathlib tactics (
scalar_tac,omega,tauto,grind) rather than only through what is written in the directory, so "small enough to read in an afternoon" is false of this workspace and true oflean/; - and above all, every theorem here is about AENEAS'S OUTPUT. The axiom list measures what the Lean kernel had to assume. It says nothing at all about whether the model is the Rust, which is TCB-30 and is the actual price of this work.
So the three-axiom pin on lean/ is untouched and still has no exceptions, and
the useful sentence is the one at the top of this section together with the four
below it, not either half on its own.
- The translation itself. Charon and Aeneas are together tens of thousands of lines of Rust and
OCaml, unverified, and the model is only as good as the translation. What would close it: nothing
available today. Aeneas has no machine-checked soundness proof of its own pipeline. The partial
mitigation that IS available is the one
aeneas/run.shimplements: re-run the translation, diff the model, and fail if it moved, so a change tosrc/boundary_core.rsthat changes the model cannot slip past review with the old proofs still in the tree. - Coverage. Seven functions is seven functions. The interner (TCB-15 to TCB-17) is a
HashMapand Aeneas does not modelstd::collections::HashMap;parse_pat(TCB-20) returnsanyhow::Resultand formats messages, which is the same thing that killed its Kani harness. Both would need the same treatment this work gave the writers: lift the pure decision out, leave the machinery where it is. - The toolchain split.
aeneas/lean/is on Lean v4.31.0 andlean/is on v4.33.1, so the two can never share a file. That closes when Aeneas's Lean backend catches up, and it will always lag by some amount because Mathlib does.