Leibniz · Trust-boundary dossier · 2026

The Calculemus Audit

A published compiler bug in Lean 4 forced a question an agentic theorem daemon could not answer from its own logs: when the system stamps Q.E.D., did a kernel decide, or did something merely say so? Settling it took nine adversarial rounds, six architecture decision records, and the discovery that the most dangerous failures were the ones that looked exactly like success.

1published compiler bug
9adversarial rounds
6decision records
3trust classes closed
65/65held laws re-verified
0known holes remaining
01 — Genesis

A correct kernel and a wrong compiler

In 2026 Trail of Bits published a soundness-relevant defect in Lean 4. The function String.Pos.Raw.extract behaved one way under its logical definition — the one the kernel reasons about — and another way in its compiled native implementation. The two disagreed.

The precise shape of the bug is what makes it interesting, and it is worth stating carefully because almost every wrong response to it comes from getting this backwards:

The kernel was not wrong. The compiler was. Lean's trusted core continued to reason correctly; the fast native code it hands work off to did not agree with it.

That distinction determines the blast radius. A proof checked by the kernel was unaffected. The defect was reachable only through the constructs that deliberately step outside the kernel and trust the compiler instead — in Lean, principally native_decide and #eval. It affected every stable release up to and including v4.33.1, and was fixed in v4.34.0-rc1.

For most Lean users this is a footnote: don't use native_decide in work you care about, which was already the received advice. For a system whose entire premise is that a machine — never a language model — decides what is true, it was something else.


02 — Why it reached us

A premise that has to be load-bearing

Leibniz is an agentic theorem daemon built on one sentence: LLMs propose; only mechanical checkers decide. Language models generate conjectures and candidate proofs. A Lean kernel, an SMT solver, and a set of exact decision procedures decide whether those are correct. A law is stamped Q.E.D. if and only if a field called kernel_verified is true, and that field is written in exactly one place in the codebase.

The motto is Leibniz's own: Calculemus — "let us calculate." The wager is that a dispute can be settled by computation rather than rhetoric. That wager is only as good as the guarantee that a computation actually happened.

So the Trail of Bits bug posed a specific question. If a proposer could get a proof accepted via native_decide, then kernel_verified would mean compiler-verified — and on an affected toolchain, the compiler could be induced to disagree with the kernel. The premise would be broken not in theory but in a way a published exploit could reach.

An initial review assessed the exposure and reached a reassuring conclusion. It had one limitation, stated plainly in its own handoff: it could not execute anything. No Lean toolchain, no container runtime. Every finding was inference from reading code.

The instruction that opened the work was to settle by execution what had been left as inference — and to distrust the summary over the primary source.

03 — Settled by execution

The guard that was never enforced

The audit's first finding was that the system already had the right check and did not run it. An axiom-footprint reader existed. It knew that a legitimate Lean proof closes over at most three canonical axioms:

STD_AXIOMS = frozenset({"propext", "Classical.choice", "Quot.sound"})

Anything else — sorryAx, an admitted lemma, a compiler-trusting axiom — is not a kernel decision. But the reader was not consulted by LeanVerifier.discharge, the sole writer of kernel_verified. The sole writer was never the sole checker.

Read as code, that is a missing call. Executed, it is this: a proof of Fermat's Last Theorem derived from False, routed through the real discharge path on the pinned kernel, came back

kernel_verified = True
trust_tier      = MECHANICAL
gate            = PASS
qed             = Q.E.D.

Nothing in the system objected. That is the difference between an audit that reads and an audit that runs, and it is why the rest of this document exists.

The allowlist that had to replace a denylist

The obvious fix — scan for the axiom Lean.ofReduceBool that native evaluation used to introduce — was already obsolete. Since Lean 4.29, native computation emits a generated axiom named after the theorem it serves (<theorem>._native.native_decide.ax_1), not a fixed name. A denylist naming ofReduceBool is blind to native computation on the very toolchain in use, and stays blind as the naming changes again.

So the check is an allowlist: anything outside the canonical three is a finding. This is the first instance of a pattern that recurs through the whole arc — a guard that enumerates what is bad is defeated by the next thing nobody enumerated.


04 — The adversarial record

Nine rounds, and every one of them landed

The repository treats adversarial review as a standing gate rather than a judgment call: a change to the trust surface is attacked before it is believed. Each round below was run against the fix produced by the round before it. Every single one found something.

R1

The check exists but nothing calls it

Confirmed by executing the real exploit through discharge. Fixed by making the sole kernel writer refuse unless the backend asserts it enforces axiom closure.

R2

A string literal blinds the comment stripper

The guard stripped Lean comments before scanning. A proof containing have s : String := "/-" opened a block comment that never closed, so the stripper swallowed the rest of the proof and the guard saw nothing — while Lean, which lexes the string correctly, elaborated the smuggled code that followed.

A second defect surfaced alongside it: narrowing the sorry scan broke because Lean writes the warning with backticks, so a plain by sorry was reported clean.

R3

The report is printed, not read

#print axioms is a command. A proof in the same file can redefine its elaborator, or simply print a report-shaped line of its own. Both were demonstrated, and both yielded kernel_verified=True on a compiler-trusting proof.

The fix was structural: ask about an unpredictable fresh probe name the proposer cannot know, so a forged line cannot stand in for the real one.

R4

Lean parses commands at any column

The smuggling guard was a column-0 scan, justified by the belief that a proof's continuation lines are indented and a new command is not. That belief is simply false — one leading space walked straight past it. An elab_rules hijack rode through on the same line as a modifier prefix.

R5

Raw strings are not escaped strings

Lean does no escape processing inside r"…", so r"\") is a complete raw string followed by a live parenthesis — which closes the guard's wrapper and reaches command position. A lexer that treats the backslash as an escape never sees the paren, and reports a safe depth.

R6

Guillemet identifiers

Lean allows «…» around identifiers, admitting characters the name-parsing regex had never anticipated.

R7

The question was aimed at the wrong constant

The worst of the series. theorem «id».foo parsed as «id», which resolves to core id — clean and axiom-free — while Lean declared the dirty id.foo. So theorem «id».foo : False := evil was stamped Q.E.D. The reporter answered honestly about a constant nobody had asked about.

The same round found the theorem name reaching bash -lc unescaped — a shell-injection vector — and a slicing bug that computed an index on comment-stripped text and applied it to the original.

The response was to stop aiming: the name was removed from the protocol entirely, and the reporter was made to report the union over every constant the module declares. Nothing to parse, nothing to mis-aim.

R8

A statement that means something else

No smuggling at all. A definitional preamble containing notation "False" => True makes theorem oops : False := trivial elaborate honestly. Every layer tells the truth; what changed is which proposition the statement was. Recorded as open, with a strict expected-failure test rather than a silent omission.

R9
9b · 9c

Three lexical constructs, three rounds, one lesson

9 — Lean lexes def «z/-» := 1 as a single identifier. The stripper treated « as punctuation, let the /- inside the name open a comment, and returned a truncated fragment — blinding five guards at once, two of which scan proposer-authored text.

9b — The fix was attacked and broke. The lexer did not know character literals, so the quote inside '"' opened a phantom string. It cut both ways: it hid a smuggled command, and it made the new fail-closed check refuse an honest theorem about the " character. A fix that refuses real proofs is an outage, not a fix.

9c — That fix was attacked and broke too. String interpolation — s!"{f "a--b"}" — desynced the lexer again, hiding a wrapper-closing paren and a smuggled macro_rules from every guard.

The pattern, stated once

Every guard that failed was a syntactic guess about Lean's grammar written in Python. Rounds 9, 9b and 9c are the same finding restated three times: a hand-written Lean lexer will keep being wrong, and when it is wrong, every guard built on it is wrong simultaneously.

Round 9c was closed not by lexing interpolation correctly but by refusing it — a measurement showed that none of the recorded theorem statements, proofs or preambles used interpolation, because they are mathematics, not IO. Removing the adversary's ability to answer beat out-parsing them.


05 — What actually held

Two checks that never read proposer syntax

Across nine rounds, two things were never broken. Both work the same way: they do not ask a question the adversary can influence.

A compiled reporter

Rather than parsing Lean source in Python, or reading a printed #print axioms line, the mint compiles the candidate into a Lean module and then runs a separately compiled binary that imports the resulting object file and reads the axiom closure straight out of ConstantInfo data via Lean.collectAxioms. It elaborates no proposer-authored syntax. It takes no declaration name to mis-aim. It was compiled when the image was built, so nothing a proposer later writes can change what it does.

A kernel replay

ADR 0097 recorded the kernel-bypass class — skipKernelTC, unsafe self-loops — as unclosable. That claim turned out to be inferred from repository metadata rather than measured. The actual port of lean4checker to the pinned toolchain was two lines: v4.34 had added a maxRecDepth : USize parameter.

With it vendored, every minted environment is replayed through a bare kernel before its footprint is even read. An axiom closure is never consulted for an environment the kernel would reject.

PROPOSER (UNTRUSTED) theorem_src proof_src syntactic guards defence in depth only compile Mint.olean DECIDES 1 · kernel replay ADR 0098 2 · axiom closure ADR 0097 3 · denotation ADR 0100 kernel_verified all three, or Q.E.I.
The three checks that decide read compiled artifacts, never proposer syntax. The dashed stage is where all nine adversarial rounds landed — and why its failures never reached Q.E.D.
Measured, not asserted

With every syntactic guard forced open and the in-file proof check subverted, an honest by decide still passes; native_decide is refused; both literal and programmatic skipKernelTC are refused. The syntactic layer is not load-bearing, and that was established by turning it off rather than by reasoning about it.


06 — A clean closure, a false statement

The class no kernel check can catch

Round 8's finding was the hardest, because nothing in it is a bug. Consider a law whose definitional preamble contains a single line:

notation "False" => True
theorem oops : False := trivial      -- 'oops' does not depend on any axioms

The kernel proved exactly the proposition it was handed. The proof is honest. The axiom closure is not merely clean — it is empty. By the only measure the axiom check applies, this exploit is cleaner than a legitimate proof. There is no axiom to catch, because no axiom was used.

First response: audit the data, and say so

The first answer was deliberately modest. Measurement showed the preamble is written only by operator-authored export scripts — nothing on the proposer path touches it — and that no published law used a meaning-changing construct. So the mechanism became a hash pin: every preamble is fingerprinted, and a change fails the build until a human reviews and re-pins it. A construct lint rides alongside as an authoring aid.

The distinction mattered, because adversarial review found seven working bypasses of that lint. Three were the same mistake: scanning Lean by line position — the lesson an earlier round had already recorded, quoted in the lint's own comments, and then violated two checks later. The pin held regardless, because a hash makes no claim about Lean's grammar.

Second response: ask what the statement denotes

Closing the class properly required a different question. The insight is that a compiled expression has already resolved every notation and every name — so the meaning is readable straight off the object file, with no parser-table diffing and nothing to keep in sync with Lean's grammar.

Before designing anything, the live attack surface was measured. Of eleven candidate attacks, four are refused by Lean itself:

AttemptedLean's own response
open / export / open … inAmbiguous term
def False at roothas already been declared
infix:50 " = "Ambiguous term
variable (h : False)unknown identifier

Lean refuses ambiguous shadowing rather than silently preferring the new name — a real part of the trust story that nobody had measured. That left eight live attacks in two classes, and both are visible in the compiled artifact:

ClassDeclared asElaborated type
syntax redefinition 4 / 4oops[True]
shadowing via open scope 4 / 4Foo.oops[Foo.False]
an honest lawgood[Eq, Nat, HAdd.hAdd, …]
The property the whole design rests on

A first research pass produced five candidate designs. All five were defeated the same way, and none had tested for it: they elaborate the preamble in-process. A preamble carrying initialize (IO.Process.exit 0) makes such a guard print nothing and exit zero — so a caller keying on the exit code accepts the attack. A run_cmd can go further and forge the guard's own success line on its real stdout, after reading the nonce out of its process arguments.

A nonce protocol does not save a design that runs the adversary's code. Reading the compiled object file with extensions disabled neutralises all of it: the same file that kills an in-process importer reads safely.

A first version of the closing guard refused every declaration that did not land at its own name. That is the right precondition for shadowing and the wrong check — an earlier round had pinned an unclosed namespace as an honest operator shape, and the regression set caught it immediately. A guard that drops real proofs is a defect even when it fails closed.


07 — The delivery path

The daemon was not running the code we had fixed

With the mint hardened, a routine check of the nightly beat's log produced the most consequential finding of the arc — and it had nothing to do with Lean.

The daemon runs itself: a scheduled beat syncs a worktree to the merged mainline and turns capped, journalled cycles against it. The governing decision record states that it "always runs the latest operator-merged code, never a working tree." That was an intention, not a check. When the sync failed, the launcher printed a warning and carried on with whatever code was already there.

NightOutcome
09-11healthy
09-12 – 09-14network failure, then abort — container runtime unreachable
09-15sync failed → ran stale code, completed, exit 0

On the last of those nights the beat ran pinned four trust-boundary records behind the mainline — missing every fix described above, including the one that closed the demonstrated Q.E.D.-on-False hole — and promulgated a law.

Nothing was published; publication is a separate human act and the queue was held. But a verdict had been minted by a build with a known hole, and nothing said so. The three aborted nights each raised an alarm. The night that ran the wrong code raised none, returned success, and journalled a normal-looking entry.

A beat that cannot run aborts loudly. A beat that ran the wrong code returned zero — indistinguishable from a healthy night in every artifact a human reads.

The fix makes a failed sync abort and alarm, verifies the worktree is actually at the merged commit rather than assuming the sync command worked, alarms on a failed fetch instead of only warning, and records which commit produced each beat.

And a worse bug, found only by running the tests

Writing tests that execute the launcher surfaced a defect that predated the fix entirely. If an ordinary directory sits where the worktree should be, git walks up and operates on the canonical repository instead:

canonical repo branch BEFORE: main
  (checkout in the squatter dir SUCCEEDED)
canonical repo branch AFTER : HEAD

The nightly daemon was one stray directory away from detaching the operator's own working checkout. The first version of the fix did not catch this either — its verification passed, because it too was reading the parent repository's state. Only running the script against a real repository revealed it.


08 — The sweep

Replacing stored verdicts with measured ones

A kernel_verified flag is only as good as the build that wrote it, and the builds had drifted. Every law in the review queue had been minted under some earlier generation of the guards. Each stored verdict was an unverifiable claim.

So all of them were re-discharged through the current mint — compile, kernel replay, axiom closure, denotation — trusting neither the stored flag, nor the stored Q.E.D., nor the source text.

65/65laws pass
8.8minutes total
4months of builds covered
0failures

A green result from a check that cannot go red is worthless, so the sweep was verified in both directions. It discriminates — three distinct axiom footprints across the corpus (43 laws closing over all three canonical axioms, 19 over two, 3 over one), not a constant answer. And it goes red on the same code path:

ControlVerdict
a real held law, untouchedok
by sorryrefused
by native_deciderefused
notation "False" => Truerefused — elaborates to True
def False in open namespacerefused — names Foo.False

One incidental result is worth recording. Many queue entries display a statement ending := by sorry, which looks alarming. The sweep confirms it is harmless: the statement is cut at its first top-level := and the real proof appended, so the fragment never reaches the kernel. That had been asserted in prose for a long time; it is now measured across every law, by a reader that inspects kernel data rather than source text.


09 — What remains open

One residue, and it is not a checker gap

Three classes are closed at the mint. One is open, deliberately, and will not be closed by any amount of kernel checking:

@[irreducible] def Riemann : Prop := True
theorem rh : Riemann := by unfold Riemann; trivial

Every layer behaves honestly. The kernel proves the proposition it is handed. The axiom closure is clean. The denotation reads Riemann — the reporter cannot see through an irreducible definition, and the shape is structurally identical to an honest operator helper.

The structural proof that no diff can separate them is concrete: a real published law's statement legitimately names six of its own preamble constants. Any rule of the form "the statement must not name a preamble-introduced constant" would refuse every honest law in the corpus.

This is a faithfulness question — does the formal statement say what the natural-language claim says? — and it belongs to a different gate, one that has existed since the project's second decision record. It is written down rather than implied, and marked in the test suite rather than omitted.

ClassStatusClosed by
axiom footprintclosedADR 0097
kernel bypassclosedADR 0098
statement meaningclosedADR 0100
preamble integritypinnedADR 0099
delivery pathclosedADR 0101
statement faithfulnessopen by constructionADR 0002 gate

10 — What the arc taught

Five findings that outlast the code

1 · "This cannot be checked" needs the same scrutiny as "this is secure"

Twice, a decision record declared a class unclosable. Both times the reasoning was sound about the wrong activity: it ruled out scanning and concluded nothing could check. The kernel-bypass class fell to a two-line port. The statement-meaning class fell to reading a compiled expression. Humility that has not been measured is just another unverified claim.

2 · The dangerous failures return success

A beat that could not run aborted loudly and was noticed. A beat that ran four records behind returned zero and was invisible. A guard blinded by a lexer desync reported clean. The recurring shape is not the alarm that fires — it is the one that cannot.

3 · Allowlists work; denylists are blind

A denylist naming the axiom native evaluation used to emit was already blind on the toolchain in use. The same logic retired a preamble construct lint in favour of a hash pin, which makes no claim about Lean's grammar and therefore cannot be out-parsed.

4 · The guards that held removed the adversary's ability to answer

Every guard that failed asked a question the adversary could influence: what does this source text say, what did this command print, which name should I look up. The two that never failed do not ask: one reads compiled kernel data, the other re-runs the kernel. When a syntactic guard failed a fourth time, the fix was to refuse the construct rather than parse it better.

5 · A green check proves nothing until it has been shown to go red

Every audit landed here carries its own falsification: mutation controls that must be refused, vacuity assertions that fail if the check examined nothing, and regression tests pinning each historical bypass. The seven bypasses of the preamble lint, the three lexer desyncs, and the stale-code beat are all tests now — because the alternative is a suite that is green because it is empty.

The meta-lesson

Nine rounds, and every round found something — including rounds run against fixes that had just been declared safe, and twice against fixes written minutes earlier. The rate never dropped. The honest reading is not that the system was unusually broken, but that adversarial review is not a milestone to pass; it is a standing cost of holding a trust boundary.


11 — Decision ledger

The record, in order

Each record states what was decided, what was measured, and — where it applies — what the record itself got wrong. Corrections are appended rather than edited, so a later reader sees the reasoning as well as the outcome.

0095 A toolchain bump moves the keys, not the proofs Pin lifted from v4.31.0 to v4.34.0-rc2 — out of the affected range merged
0096 The sole writer was never the sole checker Records the fail-open: kernel_verified set without an enforced axiom check merged
0097 Landing the axiom check Four adversarial rounds against the fix; the compiled reporter; rounds 9 / 9b / 9c appended merged
0098 Replay the kernel Closes the class ADR 0097 said could not be closed — and says so merged
0099 A clean axiom closure is not a true statement Preamble hash pin as enforcement; the construct lint as an authoring aid, with seven bypasses recorded merged
0100 The mint reads what the statement means Denotation read from the compiled artifact; closes the statement-meaning class merged
0101 The beat must not mint against stale code, silently The delivery path that had been bypassing every guard above merged

Final verification state

GateResult
Full suite2,017 passed · 47 skipped · 0 xfailed
Kernel lane (real kernel, container-gated)90 passed · 0 skipped
Recorded axiom footprints audited46 / 46 clean
Preambles linted and pinned11 / 11
Held laws re-discharged65 / 65
Adversarial regressions pinned as tests104

The suite reporting zero expected failures is the line that matters. Through most of this work it reported one — the strict marker holding the statement-meaning class open. Deleting that marker to make the suite look clean would have been the defect, not the fix. It is gone because the class closed.

Throughout, five files were never modified: the trust policy, the byte-frozen invariants, the boundary guards, the kernel-writer guards, and the verifier itself. The trust boundary was hardened from outside; it was never renegotiated to make a test pass.