<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Hideaki Takahashi on Medium]]></title>
        <description><![CDATA[Stories by Hideaki Takahashi on Medium]]></description>
        <link>https://medium.com/@Koukyosyumei?source=rss-43fb167fbb77------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*2Lx5hCk2tOhjiOVP10J6Bg.jpeg</url>
            <title>Stories by Hideaki Takahashi on Medium</title>
            <link>https://medium.com/@Koukyosyumei?source=rss-43fb167fbb77------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Thu, 05 Nov 2026 00:51:00 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@Koukyosyumei/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Building a Mini Lean 4 from Scratch: Implementing an Interactive Theorem Prover in Rust]]></title>
            <link>https://medium.com/@Koukyosyumei/building-a-mini-lean-4-from-scratch-implementing-an-interactive-theorem-prover-in-rust-a5d964d173ee?source=rss-43fb167fbb77------2</link>
            <guid isPermaLink="false">https://medium.com/p/a5d964d173ee</guid>
            <category><![CDATA[rust]]></category>
            <category><![CDATA[formal-verification]]></category>
            <category><![CDATA[security]]></category>
            <category><![CDATA[lean4]]></category>
            <category><![CDATA[cloud-security]]></category>
            <dc:creator><![CDATA[Hideaki Takahashi]]></dc:creator>
            <pubDate>Tue, 03 Nov 2026 15:03:35 GMT</pubDate>
            <atom:updated>2026-11-03T20:08:11.789Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/690/0*QWtGVJZimRLLpagR.png" /></figure><p>Everyone is talking about interactive theorem provers like Lean 4 and Coq right now. From formalizing frontier mathematics to verifying production code, the hype on social media is hard to miss. However, most people still treat these tools like magic boxes , and few actually know what happens under the hood when Lean 4 “proves” a theorem.</p><p>This post answers that question by implementing a toy Lean 4-style theorem prover from scratch. Walking through the core mechanics step by step will give you a deeper, more confident understanding of what modern theorem provers actually do, and why we can trust them.</p><p>The full source code written in Rust is available at <a href="https://github.com/h5i-dev/MiniLean4">https://github.com/h5i-dev/MiniLean4</a>.</p><h3>1. Propositions are types</h3><p>At its core, a theorem prover has one job: deciding whether a proof is valid. Because a computer cannot directly verify a proof written in plain English, we need a formal bridge between mathematics and code. That bridge is the <strong>Curry–Howard correspondence</strong>, often summarized by a simple slogan: <em>propositions are types, and proofs are programs</em>.</p><p>In programming terms, think of a mathematical proposition as a <strong>type signature</strong>, and its proof as a <strong>program (or value)</strong> that satisfies that signature. If we can write a well-typed expression <em>t</em>of type <em>A</em>(written <em>t: A</em>), then the proposition <em>A</em>is proven true. If no such program can be constructed, the proposition cannot be proven.</p><p>Consider logical implication: <em>A → B</em>( <em>A</em>implies <em>B</em>). To prove <em>A → B</em>mathematically, we assume <em>A</em>is true and use it to derive B. In programming, this is simply a function that takes an argument of type <em>A</em>and returns a result of type <em>B</em>. If you can implement that function, you have proven the implication.</p><p>The same idea extends naturally to universal quantification (“for all <em>x</em>…”). Suppose we want to prove that “for every <em>x</em>of type <em>A</em>, the property <em>B(x)</em>holds. Just like implication, we prove this by writing a function that takes any input <em>x: A</em>and returns a proof of <em>B(x)</em>.</p><p>Notice the twist here: the return type B(x)actually changes depending on the <em>value</em> of the input x. In standard programming languages, return types can’t depend on runtime values, so we need a <strong>dependent function type</strong> (or Π-type), written as:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9ltrPYn6-2024xj4JnAFtw.png" /></figure><p>For example, let’s express the statement “every proposition implies itself.” In Lean’s type theory, the universe of all propositions is written as Sort 0(or Prop). Using a dependent type, we can write this statement as:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*KSOHNEGInw8xteozmfDwng.png" /></figure><p>To prove it, we write a function that takes <em>any</em> proposition <em>P</em>and returns the identity proof for that specific <em>P</em>:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*X6MYCNgfR7RKXZQLPScOSw.png" /></figure><p>This brings us to the central insight behind modern theorem provers: <strong>proving a theorem is just writing a program of the right type, and verifying a proof is just type-checking that program.</strong> A Lean-style kernel doesn’t need to “understand” mathematical truth , and it only needs to be a type checker.</p><h3>2. Syntax</h3><p>To build our minimal theorem prover, we don’t need a massive programming language. In fact, our entire core calculus consists of just five kinds of terms (we’ll add one more later):</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Sc6jVlqhrCM2XlgPeHktEg.png" /></figure><p>Here, <em>u</em>is a natural number representing a universe level, and <em>x</em>is a variable name. Let’s break down what each construct does:</p><ul><li>Sort 𝑢: A universe containing types.</li><li>𝑥: A variable.</li><li>Π(𝑥:𝐴). 𝐵: A Π-type (dependent function type), whose result type 𝐵 may depend on the input 𝑥.</li><li>𝜆(𝑥:𝐴). 𝑡: A 𝜆-abstraction, i.e. a function that takes 𝑥 : 𝐴 and returns 𝑡.</li><li>𝑡 𝑠: Function application.</li></ul><pre>#[derive(Clone, Debug, PartialEq, Eq)]<br>pub enum Term {<br>    Sort(u64),<br>    Var(String),<br>    Pi(String, Box&lt;Term&gt;, Box&lt;Term&gt;),<br>    Lam(String, Box&lt;Term&gt;, Box&lt;Term&gt;),<br>    App(Box&lt;Term&gt;, Box&lt;Term&gt;),<br>}</pre><h4>Everything is a Term</h4><p>Notice that types and expressions share the exact same syntax. There is no separate grammar for “types” versus “values” or “proofs” versus “propositions”. Whether a given term <em>A</em>is actually a valid type is decided dynamically by the type checker. For instance, our earlier theorem (“every proposition implies itself”) and its proof are both constructed from the exact same building blocks:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1PMMHeKqwC1oXvwZdHvKEw.png" /></figure><h4>Associativity and Carring</h4><p>As in functional languages like Haskell, Lean, and OCaml, function application associates to the left, while arrows associate to the right:</p><ul><li><em>f a b </em>means (f a) b</li><li><em>A -&gt; B -&gt; C</em> means <em>A -&gt; (B -&gt; C)</em></li></ul><p>For example, 𝜆(𝑎:𝐴). 𝜆(𝑏:𝐵). 𝑎 has type 𝐴 → 𝐵 → 𝐴, and applied to 𝑥 : 𝐴 and 𝑦 : 𝐵 it is written (𝜆(𝑎:𝐴). 𝜆(𝑏:𝐵). 𝑎) 𝑥 𝑦, that is, ( (𝜆(𝑎:𝐴). 𝜆(𝑏:𝐵). 𝑎) 𝑥) 𝑦.</p><h3>3. Typing Rules as Logical Inference</h3><p>Now that we have our syntax, how does the kernel verify a proof? It checks types under a <strong>context</strong> Γ, which is an ordered list of assumptions:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*hwzUBVFZW9fTVwFWRLJk6A.png" /></figure><p>It records the type of each variable in scope, and we write dom(Γ) for the set {𝑥_1, . . . , 𝑥_𝑛}. Since C-Ext below forbids repeated names, Γ can be used like a dictionary from names to types, and T-Var is simply a lookup. Unlike a plain dictionary, however, the order matters: 𝐴_𝑖 may mention 𝑥_1, . . . , 𝑥_𝑖−1, as in 𝑃 : Sort 0, ℎ : 𝑃. We use two judgments:</p><ul><li>⊢ Γ ok: the context Γis well formed.</li><li>Γ ⊢ 𝑡 : 𝐴 under the assumptions in Γ, the term𝑡has type𝐴.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BRfL2wvWW7Q3LpYcsNkKTw.png" /></figure><p>Here,</p><pre> A<br>---<br> B</pre><p>means that A inducesB . <em>B[x -&gt; a]</em> also denotes substituting <em>x</em> in <em>B</em> with <em>a , and </em>𝐴 ≡ <em>B </em>denotes that the term A and B are equivalent (we’ll explain the definition of equivalence, later).</p><h4>Seeing Curry–Howard in Action</h4><p>To see just how directly these typing rules mirror formal logic, consider second-order propositional logic, where formulas are built from proposition variables <em>P</em>, 𝑄, . . . , implication (𝜑 → 𝜓 ), and universal quantification over propositions (∀𝑃 . 𝜑).</p><p>If we translate each logical formula 𝜑to its corresponding type 𝜑*, mapping 𝜑 → 𝜓to 𝜑* → 𝜓* and ∀𝑃 . 𝜑to Π(P : Sort 0). 𝜑* , then the standard rules of natural deduction match our kernel’s typing rules line for line (omitting well-formedness premises for readability):</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CCO9VJFqXtIBtPHzFkeAoA.png" /></figure><p>Look closely at the two columns above: if you erase the program terms (h, 𝜆, t, f, a) from the right-hand side, you get the exact rules of logical deduction on the left! Every step of a logical proof corresponds to constructing a syntax tree node in our programming language.</p><h4>What Do We Need to Implement This?</h4><p>At first glance, turning these rules into a type-checking function seems almost trivial. However, there are some subtle details are hiding inside the rules above:</p><ul><li>T-App relies on substitution. How do we substitute terms inside dependent types without accidentally capturing bound variable names?</li><li>T-Conv relies on equality (𝐴 ≡ <em>B </em>): How do we evaluate terms and decide whether two different-looking types compute to the same normal form? For example, how does the kernel know that <em>Π(P:Sort 0). P -&gt; P and </em><em>Π(Q:Sort 0). Q-&gt; Q </em>are the exact same type?</li></ul><p>Section 5 in this article will cover those topics, while we can trete them as black box functions at this point.</p><h4><strong>What About Falsehood? (Consistency)</strong></h4><p>Translating every logical proof into a well-typed term guarantees that our type checker can verify any valid deduction. For the kernel to be trustworthy, however, we also need the opposite guarantee: it must never accept a term for a false proposition. In logic, falsehood (⊥) can be defined as ∀𝑃 . <em>P</em>, the absurd claim that “every proposition holds”. In our type theory, this translates to the type:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cM7iMc04dnNVyjid-QBQ0g.png" /></figure><p>A type system in which no closed term inhabits <em>Π(P:Sort 0). P</em> is called <strong>consistent</strong>. Thus, the entire trustworthiness of our theorem prover boils down to a single property: ensuring that no closed program of type <em>Π(P:Sort 0). P</em>can ever pass the type checker.</p><h3>4. Type Checker</h3><p>Now we have some intuitive understanding of building blocks to create the type checker.</p><p>If you look back at our typing rules, you’ll notice a catch: the first five rules (T-Sort, T-Var, T-Pi, T-Lam, T-App) are triggered by the exact syntax of the expression we are looking at. The sixth rule, T-Conv (type conversion), has no syntax trigger, and it says we are allowed to simplify a type via computation <em>at any time</em>. If you are writing a program, how does the code know <em>when</em> to run T-Conv?</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BRfL2wvWW7Q3LpYcsNkKTw.png" /></figure><p>The Solution: infer vs. check The trick is that we never run T-Conv randomly. Instead, we split our type checker into two simple functions:</p><ul><li>infer(ctx, expr): Takes an expression and computes (infers) its type from the bottom up.</li><li>check(ctx, expr, expected_type): Infers the type of expr, and then checks if it equals expected_type by normalizing both and comparing them based on alpha-equivalence (will explain what it is later).</li></ul><p>In other words, T-Conv is no longer a separate step we have to guess when to apply, and it happens automatically inside check !</p><h4>4.1 The Type Checker in 35 Lines of Code</h4><p>Let’s look at the type checker first. Notice how the five match branches in infer map 1-to-1 to our five syntax constructs (Sort, Var, Pi, Lam, App):</p><pre>impl Kernel {<br>    /// Infers the type of `t` and requires it to be a universe `Sort u`.<br>    /// Returns `u`.<br>    pub fn infer_sort(&amp;self, ctx: &amp;Context, t: &amp;Term) -&gt; Result&lt;u64&gt; {<br>        match self.whnf(&amp;self.infer(ctx, t)?)? {<br>            Term::Sort(u) =&gt; Ok(u),<br>            other =&gt; Err(format!(&quot;expected a type, got {other:?}&quot;)),<br>        }<br>    }<br><br>    /// `Gamma |- t : ?`<br>    pub fn infer(&amp;self, ctx: &amp;Context, t: &amp;Term) -&gt; Result&lt;Term&gt; {<br>        match t {<br>            // T-Sort<br>            Term::Sort(u) =&gt; Ok(Term::Sort(u.checked_add(1).ok_or(&quot;universe overflow&quot;)?)),<br>            // T-Var<br>            Term::Var(x) =&gt; ctx<br>                .iter()<br>                .rev()<br>                .find(|(name, _)| name == x)<br>                .map(|(_, ty)| ty.clone())<br>                .ok_or_else(|| format!(&quot;unbound local: {x}&quot;)),<br>            // T-Pi: Sort 0 is impredicative.<br>            Term::Pi(x, domain, body) =&gt; {<br>                let domain_level = self.infer_sort(ctx, domain)?;<br>                let (x, body) = rename_binder_if_needed(ctx, x, body);<br>                let mut extended = ctx.clone();<br>                extended.push((x, (**domain).clone()));<br>                let body_level = self.infer_sort(&amp;extended, &amp;body)?;<br>                Ok(Term::Sort(if body_level == 0 {<br>                    0<br>                } else {<br>                    domain_level.max(body_level)<br>                }))<br>            }<br>            // T-Lam<br>            Term::Lam(x, domain, body) =&gt; {<br>                self.infer_sort(ctx, domain)?;<br>                let (x, body) = rename_binder_if_needed(ctx, x, body);<br>                let mut extended = ctx.clone();<br>                extended.push((x.clone(), (**domain).clone()));<br>                let body_type = self.infer(&amp;extended, &amp;body)?;<br>                Ok(Term::Pi(x, domain.clone(), Box::new(body_type)))<br>            }<br>            // T-App<br>            Term::App(f, a) =&gt; match self.whnf(&amp;self.infer(ctx, f)?)? {<br>                Term::Pi(x, domain, codomain) =&gt; {<br>                    self.check(ctx, a, &amp;domain)?;<br>                    Ok(subst(&amp;codomain, &amp;x, a))<br>                }<br>                ty =&gt; Err(format!(&quot;application of non-function: {ty:?}&quot;)),<br>            },<br>        }<br>    }<br><br>    /// `Gamma |- t : expected`, using T-Conv to compare the inferred type.<br>    pub fn check(&amp;self, ctx: &amp;Context, t: &amp;Term, expected: &amp;Term) -&gt; Result&lt;()&gt; {<br>        self.infer_sort(ctx, expected)?;<br>        let actual = self.infer(ctx, t)?;<br>        if self.is_def_eq(&amp;actual, expected)? {<br>            Ok(())<br>        } else {<br>            Err(format!(<br>                &quot;type mismatch: actual {actual:?}, expected {expected:?}&quot;<br>            ))<br>        }<br>    }<br>}</pre><h4>4.2 Watching the Kernel in Action</h4><p>To verify a theorem TheoremType with a proof proof_term, the user calls check(empty_ctx, proof_term, TheoremType). Let&#39;s see how this handles three quick scenarios.</p><p>For example, suppose we want to verify our proof of “every proposition implies itself”:</p><ul><li>Theorem (expected type): Π(𝑃:Sort 0). 𝑃 → 𝑃 (which is shorthand for Π(𝑃:Sort 0). Π (_: 𝑃. 𝑃))</li><li>Proof (expression): 𝜆(𝑃:Sort 0). 𝜆(ℎ:𝑃). ℎ</li></ul><p>When check runs infer on our proof:</p><ul><li>The outer Lam puts 𝑃:Sort 0 into ctx.</li><li>The inner Lam puts ℎ:𝑃 into ctx.</li><li>The inner body Var(h) looks up ℎin ctx and returns its type: 𝑃.</li><li>Wrapping those results back up in Πtypes, infer returns Π(𝑃:Sort 0). Π (h: 𝑃. 𝑃).</li><li>Finally, check compares Π(𝑃:Sort 0). Π (h: 𝑃. 𝑃) with Π(𝑃:Sort 0). Π (_: 𝑃. 𝑃) using alpha_eq. They match, so the proof is accepted!</li></ul><h3>5. Inductive Types</h3><p>So far, our theorem prover can verify proofs involving functions, implications, and universal quantification. But there is one obvious limitation: <strong>we cannot even define natural numbers yet!</strong></p><p>In Lean 4, we can introduce new types using inductive declarations:</p><pre>inductive Nat where<br>  | zero : Nat<br>  | succ : Nat → Nat</pre><p>This declaration tells Lean that every natural number is constructed in one of two ways:</p><ul><li>zero represents zero.</li><li>succ n represents the successor of a natural number n.</li></ul><p>For example, the number two is represented as succ (succ zero) .</p><p>But there is a deeper question: <em>How does the kernel know that mathematical induction is valid for this newly defined type? </em>We never explicitly defined an induction principle. We only declared two constructors!</p><p>The answer is that the kernel automatically generates a <em>recursor</em> from the constructors. A recursor provides both the induction principle for proving properties and the computational rules for recursively processing values.</p><h4>5.1 From Constructors to Mathematical Induction</h4><p>Consider our Nat declaration again:</p><pre>Nat.zero : Nat<br>Nat.succ : Nat → Nat</pre><p>Suppose we want to prove some property P n for every natural number n.</p><p>Since every natural number is built using zero and succ, we only need to handle two cases.</p><p><em>(Case 1: zero) </em>We must prove that the property holds for zero:</p><pre>base : P zero</pre><p><em>(Case 2: succ) </em>Suppose the property already holds for some natural number n. We need to show that it also holds for succ n:</p><pre>step : (n : Nat) → P n → P (succ n)</pre><p>Notice that P n appears as an argument to step. This is the <em>induction hypothesis</em>.</p><p>Once we have both base and step, we can conclude that P n holds for any natural number n. In other words, we have derived the mathematical induction principle:</p><pre>Nat.rec :<br>  (P : Nat → Prop) →<br>  P zero →<br>  ((n : Nat) → P n → P (succ n)) →<br>  (n : Nat) → P n</pre><p>The remarkable part is that the kernel can generate this type automatically, using nothing more than the two constructor declarations. For each constructor, the kernel creates one case. Whenever a constructor contains a recursive field, the kernel adds an induction hypothesis for that field.</p><ul><li>For zero, there are no recursive fields, so we only need P zero.</li><li>For succ, there is one recursive field n : Nat, so we need P n to construct P (succ n).</li></ul><h4>5.2 Mathematical Induction and Recursive Programming Are the Same Mechanism</h4><p>There is something even more interesting about this construction: <em>mathematical induction and recursive programming are two uses of exactly the same recursor.</em></p><p>To see why, let’s generalize the previous type. Instead of requiring P n to be a proposition, let M n be any type belonging to Sort u:</p><pre>Nat.rec :<br>  (M : Nat → Sort u) →<br>  M zero →<br>  ((n : Nat) → M n → M (succ n)) →<br>  (n : Nat) → M n</pre><p>The only difference is that the result type can now live in different universes.</p><p><em>(Case 1: Mathematical induction (u = 0))</em>:<em> </em>When u = 0, we have:</p><pre>M : Nat → Sort 0</pre><p>Since Sort 0 is Prop, M n is a proposition. Therefore, the recursor constructs proofs:</p><pre>Nat.rec :<br>  (P : Nat → Prop) →<br>  P zero →<br>  ((n : Nat) → P n → P (succ n)) →<br>  (n : Nat) → P n</pre><p>This is precisely mathematical induction.</p><p><em>(Case 2: Recursive programming (u = 1))</em>: Now suppose we want to implement a recursive function that doubles a natural number. Instead of a proposition, choose:</p><pre>M : Nat → Sort 1<br>M n = Nat</pre><p>Here, Nat is an ordinary data type living in Sort 1, not a proposition. Substituting this motive into the same recursor gives:</p><pre>Nat.rec :<br>  Nat →<br>  ((n : Nat) → Nat → Nat) →<br>  Nat → Nat</pre><p>The base case must return a Nat, and the successor case receives the previously computed result. We can now implement doubling:</p><pre>def double (n : Nat) : Nat :=<br>  Nat.rec<br>    (motive := fun _ =&gt; Nat)<br>    0<br>    (fun _ result =&gt; Nat.succ (Nat.succ result))<br>    n</pre><p>Its computation follows these equations:</p><pre>double zero     ↦ zero<br>double (succ n) ↦ succ (succ (double n))</pre><p>For example, the following result is 4:</p><pre>double (succ (succ zero))<br>  ↦ succ (succ (double (succ zero)))<br>  ↦ succ (succ (succ (succ (double zero))))<br>  ↦ succ (succ (succ (succ zero)))</pre><p>Notice that we did not introduce any new recursion mechanism. We simply used Nat.rec with a different motive. When M n : Sort 0, the result is a proof. When M n : Sort 1, the result is ordinary data. The kernel uses the same recursor, with different result types!!</p><blockquote>Strictly speaking, this works whenever elimination into the chosen universe is permitted. For Nat, both propositional and computational elimination are allowed.</blockquote><h4>5.3 Generalizing Recursors: A Tree with Three Constructors</h4><p>Natural numbers have only two constructors, and only one of them is recursive. Let’s consider a slightly more interesting inductive type:</p><pre>inductive Tree where<br>  | leaf : Tree<br>  | node : Tree → Tree<br>  | branch : Tree → Tree → Tree</pre><p>There are three ways to construct a tree:</p><pre>Tree.leaf   : Tree<br>Tree.node   : Tree → Tree<br>Tree.branch : Tree → Tree → Tree</pre><p>For example:</p><pre>Tree.branch (Tree.node Tree.leaf) Tree.leaf</pre><p>This represents a tree with four constructor occurrences. Now suppose we want to construct some result M t for every tree t, where:</p><pre>M : Tree → Sort u</pre><p>Just as with Nat, the recursor needs one case for each constructor.</p><p><em>(Case 1: leaf)</em>:<em> </em>leaf has no recursive fields, so we only need:</p><pre>m_leaf : M leaf</pre><p><em>(Case 2: node):</em> node takes one subtree t : Tree. The recursor therefore provides an induction hypothesis M t:</p><pre>m_node :<br>  (t : Tree) →<br>  M t →<br>  M (node t)</pre><p><em>(Case 3: branch): </em>branch takes two subtrees, l and r. Consequently, the recursor provides two induction hypotheses:</p><pre>m_branch :<br>  (l : Tree) →<br>  (r : Tree) →<br>  M l →<br>  M r →<br>  M (branch l r)</pre><p>Putting these cases together, the kernel generates:</p><pre>Tree.rec :<br>  (M : Tree → Sort u) →<br>  M leaf →<br>  ((t : Tree) → M t → M (node t)) →<br>  ((l : Tree) → (r : Tree) →<br>    M l → M r → M (branch l r)) →<br>  (t : Tree) → M t</pre><p>Compare this with Nat.rec. The mechanism is identical! The only differences are the number of constructors and the number of recursive fields in each constructor.</p><h4>How Does MiniLean4 Generate These Types?</h4><p>In our implementation, inductive declarations are represented by Rust structures:</p><pre>pub struct InductiveSpec {<br>    pub name: String,<br>    pub params: Context,<br>    pub indices: Context,<br>    pub universe_level: u64,<br>    pub constructors: Vec&lt;ConstructorSpec&gt;,<br>}<br><br>pub struct ConstructorSpec {<br>    pub name: String,<br>    pub fields: Context,<br>    pub result_indices: Vec&lt;Term&gt;,<br>}</pre><p>For Tree, both params and indices are empty, and we will explain why we need them later. The three constructors have zero, one, and two fields, respectively.</p><p>The recursor generator examines each constructor and adds induction hypotheses for its recursive fields. Conceptually, the code looks like this:</p><pre>for constructor in &amp;inductive.constructors {<br>    let mut binders = constructor.fields.clone();<br><br>    for recursive_field in &amp;constructor.recursive_fields {<br>        let ih_type = /* motive applied to recursive field */;<br>        binders.push((&quot;__ih&quot;.into(), ih_type));<br>    }<br>    let goa<br>l = /* motive applied to constructor result */;<br>    let minor_premise = telescope(&amp;binders, goal);<br>}</pre><p>Here, telescope builds nested dependent function types from a list of typed arguments. For branch, the kernel starts with:</p><pre>l : Tree<br>r : Tree</pre><p>Because both fields are recursive, it adds:</p><pre>ih_l : M l<br>ih_r : M r</pre><p>Finally, it produces:</p><pre>(l : Tree) →<br>(r : Tree) →<br>M l →<br>M r →<br>M (branch l r)</pre><p>This is exactly the branch case of Tree.rec.</p><h4>5.4 How Does the Recursor Actually Compute? Iota Reduction</h4><p>We have generated the type of Tree.rec, but we still need to explain how it computes. Let’s use the recursor to count the number of constructor occurrences in a tree. We want:</p><pre>size(leaf)       = 1<br>size(node t)     = 1 + size(t)<br>size(branch l r) = 1 + size(l) + size(r)</pre><p>Choose the motive:</p><pre>M t = Nat</pre><p>The three cases become:</p><pre>m_leaf   = 1<br>m_node   = λ t ih. 1 + ih<br>m_branch = λ l r ih_l ih_r. 1 + ih_l + ih_r</pre><p>Now consider:</p><pre>size(branch (node leaf) leaf)</pre><p>How does the kernel evaluate this expression? It inspects the constructor of the tree. Since the constructor is branch, it selects m_branch and recursively processes both subtrees.</p><p>More generally, Tree.rec follows three computation rules:</p><pre>Tree.rec M m_leaf m_node m_branch leaf<br>  ↦ m_leaf<br><br>Tree.rec M m_leaf m_node m_branch (node t)<br>  ↦ m_node t<br>      (Tree.rec M m_leaf m_node m_branch t)<br><br>Tree.rec M m_leaf m_node m_branch (branch l r)<br>  ↦ m_branch l r<br>      (Tree.rec M m_leaf m_node m_branch l)<br>      (Tree.rec M m_leaf m_node m_branch r)</pre><p>These are called <strong>ι-reduction (iota-reduction) rules</strong>. For our example:</p><pre>size(branch (node leaf) leaf)<br>  ↦ 1 + size(node leaf) + size(leaf)<br>  ↦ 1 + (1 + size(leaf)) + 1<br>  ↦ 1 + (1 + 1) + 1<br>  = 4</pre><p>The kernel does not need a hardcoded implementation of Tree.size. It only needs to know which constructor was used, which case to select, and which recursive fields need to be processed.</p><h4>5.5 Strict Positivity: Why Some Constructors Are Forbidden</h4><p>One of the importnat thing about inductive types is that not every constructor type is safe. Consider this declaration:</p><pre>inductive Bad where<br>  | mk : (Bad → False) → Bad</pre><p>At first glance, it looks similar to our recursive Tree constructors. But there is a crucial difference. Compare:</p><pre>Tree → Tree           -- Allowed<br>(Bad → False) → Bad   -- Rejected</pre><p>In the first case, the constructor directly receives a value of the inductive type. In the second case, the constructor receives a function that takes the inductive type as an argument.</p><p>Why does this matter? Suppose we allowed the second constructor and generated an unrestricted recursor. We could define:</p><pre>f : Bad → False<br>f (Bad.mk g) = g (Bad.mk g)</pre><p>Here, g : Bad → False, and Bad.mk g : Bad, so the right-hand side has type False. Now substitute f for g:</p><pre>f (Bad.mk f)<br>  ↦ f (Bad.mk f)<br>  ↦ f (Bad.mk f)<br>  ↦ ...</pre><p>The computation never terminates, and the unrestricted recursor would allow us to construct a closed term of type False. This would make the logic inconsistent.</p><p>To prevent this, inductive declarations must satisfy <strong>strict positivity</strong>. In our simplified kernel, recursive fields must have one of the following forms:</p><pre>I<br>B → I<br>B₁ → B₂ → I<br>...</pre><p>Here, I is the inductive type being defined, and none of the argument types B, B₁, B₂, etc. may contain I. For example:</p><pre>Tree                 -- Allowed<br>Nat → Tree           -- Allowed<br>Tree → Nat           -- Not a valid recursive field<br>(Tree → Nat) → Tree  -- Rejected</pre><p>Why is Nat → Tree allowed? Because the function produces a Tree. We can recursively process every tree returned by that function. Suppose a constructor contains:</p><pre>f : Nat → Tree</pre><p>Then its induction hypothesis has type:</p><pre>ih : (n : Nat) → M (f n)</pre><p>In contrast, a field of type Tree → Nat consumes trees rather than producing them. It does not provide the recursive subtrees needed by this recursor construction.</p><h4>5.6 Parameters and Indices: From Lists to Length-Indexed Vectors</h4><p>Our Nat and Tree examples had no parameters or indices. Let’s now consider a polymorphic list:</p><pre>inductive List (A : Type) where<br>  | nil : List A<br>  | cons : A → List A → List A</pre><p>Here, A is a <strong>parameter</strong>. It determines the type of the elements stored in the list. For example:</p><pre>List Nat<br>List Bool</pre><p>are different types, but within a particular list, the element type remains fixed.</p><p>The constructors have types:</p><pre>nil  : List A<br>cons : A → List A → List A</pre><p>The recursor can therefore use the following cases:</p><pre>m_nil : M nil<br><br>m_cons :<br>  (a : A) →<br>  (xs : List A) →<br>  M xs →<br>  M (cons a xs)</pre><p>This is exactly the same mechanism we used for Nat and Tree. But ordinary lists do not encode their lengths in their types. Both an empty list and a list of three natural numbers have type List Nat.</p><p>What if we want the length to be part of the type? We can define a length-indexed vector:</p><pre>inductive Vec (A : Type) : Nat → Type where<br>  | nil : Vec A 0<br>  | cons : (n : Nat) → A → Vec A n → Vec A (Nat.succ n)</pre><p>Now:</p><pre>Vec Nat 0<br>Vec Nat 2</pre><p>are different types.</p><p>The distinction is:</p><ul><li>A is a parameter, fixed throughout the recursive computation.</li><li>n is an index, which may change depending on the constructor.</li></ul><p>The constructors make this explicit:</p><pre>nil  : Vec A 0<br><br>cons : (n : Nat) →<br>       A →<br>       Vec A n →<br>       Vec A (succ n)</pre><p>The cons constructor receives a vector of length n and produces one of length succ n. This information changes the recursor’s motive.</p><p>For ordinary lists:</p><pre>M : List A → Sort u</pre><p>For vectors:</p><pre>M : (n : Nat) → Vec A n → Sort u</pre><p>The motive must now depend on both the vector and its length. In particular, the cons case becomes:</p><pre>m_cons :<br>  (n : Nat) →<br>  (a : A) →<br>  (xs : Vec A n) →<br>  M n xs →<br>  M (succ n) (cons n a xs)</pre><p>Notice that the induction hypothesis concerns a vector of length n, while the result concerns a vector of length succ n.</p><p>Indices allow the kernel to express and verify how type-level information changes across constructors. In our implementation, parameters and indices are stored separately in InductiveSpec. Each constructor also stores its result_indices.</p><p>During recursor generation, the kernel uses the recursive field’s indices to construct the induction hypothesis and the constructor’s result indices to construct the goal. Parameters are fixed before introducing the motive, while indices become arguments to the motive itself.</p><h4>5.7 Equality Is Also an Inductive Type!</h4><p>So far, we have used inductive types to represent data such as natural numbers, trees, and lists. But here is something surprising: <strong>even mathematical equality can be defined as an inductive type!</strong></p><p>In Lean 4, equality is defined essentially as follows:</p><pre>inductive Eq (A : Type) (a : A) : A → Prop where<br>  | refl : Eq A a a</pre><p>Let’s unpack this definition. Eq A a b represents the proposition that a equals b. It has two parameters, A and a, and one index, b. Unlike Nat or Tree, equality has only one constructor:</p><pre>Eq.refl : (A : Type) → (a : A) → Eq A a a</pre><p>In other words, <strong>the only way to construct an equality proof directly is to show that something equals itself. </strong>But how can this single constructor justify rewriting an expression using an equality proof? The answer, once again, is the recursor!</p><h4>Deriving Eq.rec</h4><p>Just as we generated Nat.rec and Tree.rec, the kernel generates a recursor for Eq. Suppose we want to establish a property M b h for any value b and equality proof h : Eq A a b. Our motive has type:</p><pre>M : (b : A) → Eq A a b → Sort u</pre><p>Since equality has only one constructor, refl, we only need to handle one case:</p><pre>base : M a (Eq.refl A a)</pre><p>From this, the recursor allows us to construct the property for any equality proof:</p><pre>Eq.rec :<br>  (A : Type) →<br>  (a : A) →<br>  (M : (b : A) → Eq A a b → Sort u) →<br>  M a (Eq.refl A a) →<br>  (b : A) →<br>  (h : Eq A a b) →<br>  M b h</pre><p>This may look complicated, but the meaning is simple: <em>If a property holds when two values are identical, it also holds whenever we have a proof that those values are equal.</em></p><h4>Equality Gives Us Rewriting</h4><p>For example, suppose we have:</p><pre>h : Eq Nat a b<br>P : Nat → Prop<br>pa : P a</pre><p>We want to construct a proof of P b. Choose the motive:</p><pre>M b h = P b</pre><p>The base case is simply pa : P a. Therefore, Eq.rec gives us:</p><pre>Eq.rec Nat a M pa b h : P b</pre><p>This is exactly the logical principle behind rewriting with equality. The computation rule is also straightforward:</p><pre>Eq.rec A a M base a (Eq.refl A a)<br>  ↦ base</pre><p>When the equality proof is refl, the recursor simply returns the base case. This is another example of ι-reduction, using precisely the same general mechanism as Nat.rec and Tree.rec.</p><h4>Why Is Eq Different from Nat?</h4><p>There is one important difference: Eq A a b lives in Prop, or Sort 0, while Nat lives in Sort 1.</p><p>Lean generally restricts eliminating arbitrary proofs in Prop into computational data types. However, equality is a special case: it has only one constructor, with no fields, so its recursor may eliminate into larger universes. Our implementation checks this restriction when registering inductive declarations.</p><p>The important takeaway is that <strong>equality does not need to be a primitive logical rule hardcoded into the kernel</strong>. Its elimination principle can be generated from an inductive declaration, just like the induction principle for natural numbers.</p><h3>6. Tactics: Building an Interactive Theorem Prover</h3><p>We now have a kernel capable of checking proof terms, defining inductive types, and evaluating recursive computations. In principle, this is already enough to prove many theorems. But there is a usability problem.</p><p>Consider our very first theorem:</p><pre>(P : Prop) → P → P</pre><p>We can prove it by writing a lambda expression directly:</p><pre>fun (P : Prop) (h : P) =&gt; h</pre><p>For this simple theorem, writing a proof term is easy. But for larger theorems, manually constructing deeply nested lambda expressions, function applications, and recursors quickly becomes painful.</p><p>This is where <em>tactics</em> come in. Instead of writing the complete proof term ourselves, we can interactively transform a theorem into smaller goals using commands such as intro, apply, rw, and induction.</p><p>For example:</p><pre>theorem identity (P : Prop) : P → P := by<br>  intro h<br>  exact h</pre><p>Although this looks very different from a lambda expression, both proofs ultimately represent the same term:</p><pre>fun (h : P) =&gt; h</pre><p>The crucial point is that tactics do not introduce new logical rules. They construct proof terms that are checked by the same kernel we already implemented.</p><h4>6.1 What Is a Goal?</h4><p>Suppose we want to prove:</p><pre>P → P</pre><p>In tactic mode, Lean displays something like:</p><pre>P : Prop<br>⊢ P → P</pre><p>The symbol ⊢ separates the local context from the proposition we want to prove. We can represent a goal as two pieces of information:</p><pre>struct Goal {<br>    context: Context,<br>    target: Term,<br>}</pre><p>Here, context contains the available variables and hypotheses, while target is the type of the proof term we need to construct.</p><p>For example:</p><pre>Context:<br>  P : Prop<br><br>Target:<br>  P → P</pre><p>corresponds to a goal whose context contains P : Prop and whose target is P → P. Mathematically, this is the typing judgment:</p><pre>P : Prop ⊢ ?proof : P → P</pre><p>The question mark means that the proof term has not been constructed yet. We call such an unfinished term a <em>metavariable</em> or <em>hole</em>.</p><p>For now, imagine that every goal represents a missing part of a proof. A tactic either fills that missing part or replaces it with smaller holes.</p><p>Let’s implement two of the most basic tactics in the next few subsections.</p><h4><strong>6.2. `intro`</strong>: Turn an Implication into an Assumption</h4><p>Suppose our goal is:</p><pre>P : Prop<br>⊢ P → P</pre><p>We execute:</p><pre>intro h</pre><p>The goal changes to:</p><pre>P : Prop<br>h : P<br>⊢ P</pre><p>What happened? We were trying to construct a function of type P → P. The intro tactic introduced its argument h : P, leaving us to construct the function body. This is exactly the same operation as constructing a lambda expression.</p><p>Before:</p><pre>?proof : P → P</pre><p>After:</p><pre>?proof = fun (h : P) =&gt; ?body</pre><p>where:</p><pre>h : P ⊢ ?body : P</pre><p>We have not completed the proof. We have only replaced one hole with another, smaller hole. More generally, if the goal is:</p><pre>Γ ⊢ (x : A) → B</pre><p>then intro x transforms it into:</p><pre>Γ, x : A ⊢ B</pre><p>The resulting proof term will be:</p><pre>fun (x : A) =&gt; body</pre><p>This corresponds directly to the T-Lam typing rule from Section 3.</p><h4><strong>6.3. `exact</strong>`: Fill the Hole with a Proof Term</h4><p>We now have:</p><pre>P : Prop<br>h : P<br>⊢ P</pre><p>Execute:</p><pre>exact h</pre><p>The tactic checks that h has the expected type P.</p><p>Since:</p><pre>h : P</pre><p>the goal is solved.</p><p>The generated proof term is simply:</p><pre>h</pre><p>Combining the two tactics, we obtain:</p><pre>fun (h : P) =&gt; h</pre><p>which the kernel can verify using the ordinary typing rules. This also suggests a straightforward implementation for exact. Conceptually, the tactic needs to do little more than:</p><pre>fn exact(kernel: &amp;Kernel, goal: &amp;Goal, term: &amp;Term) -&gt; Result&lt;()&gt; {<br>    kernel.check(&amp;goal.context, term, &amp;goal.target)<br>}</pre><p>This simplified function checks whether a term solves the goal. A complete tactic engine must also store the term in the corresponding proof hole.</p><p>Notice that exact does not decide whether a proposition is mathematically true. It delegates type checking to the kernel.</p><h4>6.3 apply: Proving a Goal Backwards</h4><p>Now consider a slightly more interesting theorem:</p><pre>theorem compose (P Q R : Prop)<br>    (f : P → Q) (g : Q → R) (p : P) : R := by<br>  apply g<br>  apply f<br>  exact p</pre><p>The initial goal is:</p><pre>f : P → Q<br>g : Q → R<br>p : P<br>⊢ R</pre><p>We have a function g : Q → R. If we can construct a proof of Q, applying g will give us a proof of R.</p><p>Therefore, executing:</p><pre>apply g</pre><p>changes the goal to:</p><pre>⊢ Q</pre><p>Similarly, since f : P → Q, executing:</p><pre>apply f</pre><p>changes the goal to:</p><pre>⊢ P</pre><p>Finally:</p><pre>exact p</pre><p>closes the proof. The complete proof term is:</p><pre>g (f p)</pre><p>This is an important distinction between apply and exact.</p><ul><li>exact g fails because g has type Q → R, not R.</li><li>apply g succeeds because it creates a new goal for the missing argument of g.</li></ul><p>apply can also generate multiple goals Suppose we have:</p><pre>f : P → Q → R<br>⊢ R</pre><p>Applying f requires two arguments:</p><pre>?p : P<br>?q : Q</pre><p>The tactic generates two goals:</p><pre>Goal 1:<br>⊢ P<br><br>Goal 2:<br>⊢ Q</pre><p>The partially constructed proof term is:</p><pre>f ?p ?q</pre><p>Once both holes are filled, the proof is complete. This is why an interactive theorem prover needs to manage multiple goals rather than only a single target. A simple tactic state might look like:</p><pre>struct TacticState {<br>    goals: Vec&lt;Goal&gt;,<br>}</pre><p>However, this is not yet sufficient to reconstruct the complete proof term. We also need to remember how the goals fit together. That leads us to metavariables.</p><h4>6.4 Metavariables: How the Kernel Gets a Complete Proof</h4><p>So far, we have described tactics as transforming goals. But there is a subtle problem. Suppose we start with:</p><pre>⊢ P → P</pre><p>and execute:</p><pre>intro h</pre><p>We now have:</p><pre>h : P<br>⊢ P</pre><p>If we only store the new goal, we have forgotten that its solution must be wrapped in a lambda expression.</p><p>Similarly, after apply g, we need to remember that the eventual proof must be an application of g. In other words, a list of goals is not enough. We must also track the partially constructed proof term.</p><p>We can extend our expression language with a metavariable:</p><pre>enum Term {<br>    // Existing term constructors...<br>    MetaVar(usize),<br>}</pre><p>This is an illustration of the tactic layer’s internal representation. Unlike ordinary kernel terms, metavariables are not accepted as unfinished proofs by the trusted kernel.</p><p>We can maintain a mapping from metavariable IDs to their assignments. For example, start with:</p><pre>?0 : P → P</pre><p>After intro h, assign:</p><pre>?0 := fun (h : P) =&gt; ?1</pre><p>where:</p><pre>?1 : P</pre><p>After exact h, assign:</p><pre>?1 := h</pre><p>Substituting the assignments gives:</p><pre>?0 = fun (h : P) =&gt; h</pre><p>No holes remain.</p><p>The tactic engine can now send the complete term to the kernel:</p><pre>kernel.check(&amp;empty_context, &amp;proof, &amp;theorem_type)?;</pre><p>This final check is essential. A tactic implementation may have bugs. It might introduce an incorrect substitution, generate malformed applications, or make invalid assumptions while manipulating goals. But as long as every resulting proof is independently checked by a sound kernel, a bug in a tactic cannot make an invalid theorem pass verification.</p><h4>6.5 rw: Rewriting with Equality</h4><p>In Section 5, we defined equality as an inductive type and generated its recursor. Recall that an equality proof has type:</p><pre>h : Eq A a b</pre><p>Intuitively, h tells us that a and b are equal. The rw tactic uses this proof to replace one expression with another.</p><p>Consider:</p><pre>theorem rewrite_example<br>    (P : Nat → Prop) (a b : Nat)<br>    (h : a = b) (pa : P a) : P b := by<br>  rw [← h]<br>  exact pa</pre><p>Initially, the goal is:</p><pre>P : Nat → Prop<br>a b : Nat<br>h : a = b<br>pa : P a<br>⊢ P b</pre><p>The command:</p><pre>rw [← h]</pre><p>rewrites the target using the equality in the reverse direction, from b to a. The new goal is:</p><pre>⊢ P a</pre><p>Now exact pa solves it.</p><p>But why is this logically sound? Because the rw tactic can construct a proof using the equality recursor from Section 5. Recall the general idea of Eq.rec:</p><pre>Eq.rec :<br>  (M : (b : A) → Eq A a b → Sort u) →<br>  M a (refl a) →<br>  (b : A) →<br>  (h : Eq A a b) →<br>  M b h</pre><p>For our example, choose:</p><pre>M b h = P b</pre><p>Then the base case requires a proof of P a, which we already have. Applying Eq.rec transports this proof along h : a = b and produces a proof of P b. Conceptually, the completed proof is:</p><pre>Eq.rec (M := fun b _ =&gt; P b) pa b h</pre><p>The precise Lean 4 elaboration may use specialized equality-transport constants and additional implicit arguments, but the logical mechanism is the same.</p><h4>6.6 induction: Using the Recursor as a Tactic</h4><p>Now we can connect the tactic system to the inductive types we implemented in Section 5.</p><p>Recall that Nat.rec has the type:</p><pre>Nat.rec :<br>  (M : Nat → Sort u) →<br>  M zero →<br>  ((n : Nat) → M n → M (succ n)) →<br>  (n : Nat) → M n</pre><p>When M n is a proposition, this is exactly mathematical induction. Therefore, an induction tactic does not need to introduce another logical principle. It only needs to construct an application of Nat.rec.</p><p>Suppose our goal is:</p><pre>n : Nat<br>⊢ P n</pre><p>where:</p><pre>P : Nat → Prop</pre><p>Execute:</p><pre>induction n with<br>| zero =&gt; ...<br>| succ k ih =&gt; ...</pre><p>The tactic creates two goals.</p><p><em>Base case:</em></p><pre>⊢ P zero</pre><p><em>Inductive step:</em></p><pre>k : Nat<br>ih : P k<br>⊢ P (succ k)</pre><p>These are precisely the two arguments required by Nat.rec. The proof term being constructed has the form:</p><pre>Nat.rec<br>  (M := P)<br>  ?base<br>  (fun k ih =&gt; ?step)<br>  n</pre><p>Here, ?base and ?step are metavariables that the remaining tactics must fill.</p><p>Let’s think about a concrete exmaple of proving n + 0 = n.<strong> </strong>Consider:</p><pre>theorem add_zero (n : Nat) : n + 0 = n := by<br>  induction n with<br>  | zero =&gt;<br>      rfl<br>  | succ n ih =&gt;<br>      rw [Nat.succ_add, ih]</pre><p>The initial goal is:</p><pre>n : Nat<br>⊢ n + 0 = n</pre><p>After induction n, we obtain:</p><pre>Case zero:<br>⊢ 0 + 0 = 0</pre><p>and:</p><pre>Case succ:<br>n : Nat<br>ih : n + 0 = n<br>⊢ (succ n) + 0 = succ n</pre><p>In the base case, both sides compute to the same value, so rfl succeeds.</p><p>In the successor case, Nat.succ_add rewrites the left-hand side using the recursive definition of addition, and ih completes the proof. The generated proof has the same essential structure as:</p><pre>Nat.rec<br>  (M := fun n =&gt; n + 0 = n)<br>  base_proof<br>  (fun n ih =&gt; step_proof)<br>  n</pre><p>This is a direct application of the recursor we already generated from the Nat constructors.</p><p>Note that the exact rewriting steps depend on how addition is defined. In Lean 4, a simpler proof of n + 0 = n is also available by computation, but the induction-based version makes the generated recursor explicit.</p><h4>Generalizing induction Beyond Nat</h4><p>For the Tree type from Section 5:</p><pre>inductive Tree where<br>  | leaf : Tree<br>  | node : Tree → Tree<br>  | branch : Tree → Tree → Tree</pre><p>the tactic:</p><pre>induction t with<br>| leaf =&gt; ...<br>| node child ih =&gt; ...<br>| branch left right ih_left ih_right =&gt; ...</pre><p>generates three goals instead of two.</p><p>The node case receives one induction hypothesis, while the branch case receives two. This is not special logic implemented for trees. The tactic simply uses the constructor cases required by Tree.rec. The general principle is:</p><blockquote>An induction tactic turns a goal into the constructor cases required by the inductive type’s recursor, then assembles their proofs into a recursor application.</blockquote><p>That is why the inductive type machinery from Section 5 is so useful: we can build a generic induction tactic rather than implementing a separate induction rule for every type.</p><h4>6.7 Implementing a Minimal Tactic Engine in Rust</h4><p>We now have enough pieces to sketch a small tactic engine. The kernel already provides trusted operations such as:</p><pre>kernel.infer(...)<br>kernel.check(...)<br>kernel.is_def_eq(...)</pre><p>The tactic engine adds a separate layer that manages unfinished proofs. A possible minimal design is:</p><pre>struct ProofGoal {<br>    context: Context,<br>    target: Term,<br>    metavariable_id: usize,<br>}<br><br>struct TacticState {<br>    goals: Vec&lt;ProofGoal&gt;,<br>    assignments: HashMap&lt;usize, Term&gt;,<br>    next_metavariable_id: usize,<br>}</pre><p>The responsibilities of the main tactics can then be summarized as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ofBhwt2MgFadjJes1FYtqA.png" /></figure><p>A tactic is therefore just a program that reads the current proof state, creates or assigns metavariables, and updates the remaining goals.</p><p>For example, the essence of intro is:</p><pre>fn intro(state: &amp;mut TacticState, name: &amp;str) -&gt; Result&lt;()&gt; {<br>    let goal = state.pop_goal()?;<br><br>    let (binder, domain, body) = expect_pi(&amp;goal.target)?;<br>    let new_target = rename_binder_body(&amp;body, &amp;binder, name);<br>    let new_goal = state.fresh_goal(<br>        extend_context(&amp;goal.context, name, &amp;domain),<br>        new_target,<br>    );<br>    state.assign(<br>        goal.metavariable_id,<br>        lam(name, domain, new_goal.as_term()),<br>    )?;<br>    state.push_goal(new_goal);<br>    Ok(())<br>}</pre><p>intro does not prove anything by itself. It replaces a hole with a lambda expression containing another hole. Likewise, apply replaces a hole with a function application containing holes for the missing arguments.</p><h3>7. The Machinery Under the Hood: Substitution, 𝛼-Equivalence, and Conversion</h3><p>The above type checker internally calles various functional operations to compare and normalize expressions: <em>capture-avoiding substitution</em> (for T-App and function evaluation), <em>𝛼-equivalence</em><strong> </strong>(for comparing types up to bound variable names), and <strong><em>conversion</em></strong><em> </em>(for T-Conv).</p><h4>7.1 Free Variables and Capture-Avoiding Substitution</h4><p>In our syntax, <em>Π (x: A). B</em>and <em>𝜆(x: A)</em>, <em>t</em>act as <strong>binders</strong>: any occurrence of <em>x</em>inside the body Bor trefers to the bound parameter. Crucially, the binder’s scope covers only the body , and xis <em>not</em> bound inside the type annotation A. Variables that are not bound by any enclosing <em>Π</em>or <em>𝜆</em>are called free variables, written FT(x):</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5KcMsWuCdRAGZkNvvxQ_4Q.png" /></figure><pre>/// The free variables `FV(t)`.<br>pub fn fv(t: &amp;Term) -&gt; HashSet&lt;String&gt; {<br>    match t {<br>        Term::Sort(_) =&gt; HashSet::new(),<br>        Term::Var(x) =&gt; HashSet::from([x.clone()]),<br>        Term::App(f, a) =&gt; {<br>            let mut names = fv(f);<br>            names.extend(fv(a));<br>            names<br>        }<br>        Term::Pi(x, a, b) | Term::Lam(x, a, b) =&gt; {<br>            let mut names = fv(a);<br>            let mut body_names = fv(b);<br>            body_names.remove(x);<br>            names.extend(body_names);<br>            names<br>        }<br>    }<br>}</pre><p>Suppose we want to compute <em>(𝜆 (y: A). x) [x -&gt; y]</em>. The original function <em>(𝜆 (y: A). x)</em>ignores its argument <em>y</em>and returns the outer free variable <em>x</em>. If we naively replace <em>x</em>with <em>y</em>, we get <em>(𝜆 (y: A). y)</em> , which is the identity function! The free variable <em>y</em>has been accidentally “captured” by the <em>𝜆 (y: A)</em> binder, completely changing the meaning of the program.</p><p>To prevent this, substitution must be <strong>capture-avoiding</strong>: whenever a binder’s variable <em>y</em>appears free in the replacement term<em>s (y \in FV(s))</em>, we must first rename the binder to a fresh variable <em>z</em>before descending into the body.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1017/1*zMTlb1tuXIdsK2B1zcB_uQ.png" /></figure><pre>pub fn subst(t: &amp;Term, x: &amp;str, replacement: &amp;Term) -&gt; Term {<br>    match t {<br>        Term::Sort(_) =&gt; t.clone(),<br>        Term::Var(y) =&gt; {<br>            if y == x {<br>                replacement.clone()<br>            } else {<br>                t.clone()<br>            }<br>        }<br>        Term::App(f, a) =&gt; app(subst(f, x, replacement), subst(a, x, replacement)),<br>        Term::Pi(y, domain, body) | Term::Lam(y, domain, body) =&gt; {<br>            let substituted_domain = subst(domain, x, replacement);<br>            let (renamed_binder, substituted_body) = if y == x || !fv(body).contains(x) {<br>                (y.clone(), (**body).clone())<br>            } else if !fv(replacement).contains(y) {<br>                (y.clone(), subst(body, x, replacement))<br>            } else {<br>                let mut avoid = all_names(body);<br>                avoid.extend(all_names(domain));<br>                avoid.extend(all_names(replacement));<br>                avoid.insert(x.to_string());<br>                let z = fresh(y, &amp;avoid);<br>                let body_with_z = subst(body, y, &amp;var(&amp;z));<br>                (z, subst(&amp;body_with_z, x, replacement))<br>            };<br>            let domain = Box::new(substituted_domain);<br>            let body = Box::new(substituted_body);<br>            if matches!(t, Term::Pi(..)) {<br>                Term::Pi(renamed_binder, domain, body)<br>            } else {<br>                Term::Lam(renamed_binder, domain, body)<br>            }<br>        }<br>    }<br>}</pre><h4>7.2 alpha-Equivalence: Variable Names Don’t Matte</h4><p>In any programming language, renaming a function’s parameter doesn’t change what the function does. For example, fun (x: A) =&gt; x is the exact same function as fun (y: A) =&gt; y .</p><p>Similarly, in our theorem prover, Π(𝑃:Sort 0). 𝑃 → 𝑃and Π(𝑄:Sort 0). 𝑄 → Q express the exact same proposition. Terms that are identical except for the names of their bound variables are called 𝛼-equivalent (written as =𝛼).</p><p>When the kernel checks whether two types are equal, a simple string or AST equality check (s == t) won&#39;t work because P and Q are different variable names. On the other hand, repeatedly renaming variables in the AST to make them match is slow and messy, how do we know which fresh names to pick?</p><p>While the detailed mechanism is out of the scope of this article, alpha-equivalence check usually compare binder positions instead of variable names, and the kernel walks both ASTs side-by-side and compares how many binders outward each variable points.</p><p>For example, suppose we want to check whether these two nested functions are alpha-equivalent:</p><ul><li>𝜆(𝑥:𝐴). 𝜆(𝑦:𝐵). x</li><li>𝜆(𝑎:𝐴). 𝜆(𝑏:𝐵). a</li></ul><p>As the algorithm steps inside the binders on both sides, it records the parameter names in two lists (stacks), E1 and E2:</p><ul><li>Pass the first binder: E1 = [x] and E2 = [a].</li><li>Pass the second binder: E1 = [x, y]and E2 = [a, b].</li></ul><p>We then look up how far back each variable appears in its respective list (counting from the innermost binder at index 0).</p><ul><li>In E1 = [x, y], x is at distance <strong>1</strong> from the right.</li><li>In E2 = [a, b], a is also at distance <strong>1</strong> from the right.</li></ul><pre>pub fn alpha(a: &amp;Term, b: &amp;Term) -&gt; bool {<br>    fn binder_distance(bound: &amp;[String], x: &amp;str) -&gt; Option&lt;usize&gt; {<br>        bound.iter().rev().position(|name| name == x)<br>    }<br>    fn go(a: &amp;Term, b: &amp;Term, bound_a: &amp;mut Vec&lt;String&gt;, bound_b: &amp;mut Vec&lt;String&gt;) -&gt; bool {<br>        match (a, b) {<br>            (Term::Sort(u), Term::Sort(v)) =&gt; u == v,<br>            (Term::Var(x), Term::Var(y)) =&gt; {<br>                match (binder_distance(bound_a, x), binder_distance(bound_b, y)) {<br>                    (Some(i), Some(j)) =&gt; i == j,<br>                    (None, None) =&gt; x == y,<br>                    _ =&gt; false,<br>                }<br>            }<br>            (Term::App(f, a), Term::App(g, b)) =&gt; {<br>                go(f, g, bound_a, bound_b) &amp;&amp; go(a, b, bound_a, bound_b)<br>            }<br>            (Term::Pi(x, a, b), Term::Pi(y, c, d)) | (Term::Lam(x, a, b), Term::Lam(y, c, d)) =&gt; {<br>                if !go(a, c, bound_a, bound_b) {<br>                    return false;<br>                }<br>                bound_a.push(x.clone());<br>                bound_b.push(y.clone());<br>                let bodies_equal = go(b, d, bound_a, bound_b);<br>                bound_a.pop();<br>                bound_b.pop();<br>                bodies_equal<br>            }<br>            _ =&gt; false,<br>        }<br>    }<br>    go(a, b, &amp;mut vec![], &amp;mut vec![])<br>}</pre><h4>7.3 beta-Reduction: Simplyfing the dependent types</h4><p>In languages like Rust, TypeScript, or Java, type checking is purely static: the compiler never has to <em>run</em> your functions just to check if two types match.</p><p>In a dependent type theory like Lean, however, types can contain ordinary function calls. And that means two types that look completely different on the surface might actually be the same type once you evaluate them!</p><p>For example, let Id = 𝜆(𝑋:Sort 0). X be the identity function on propositions. Consider these two types:</p><ul><li>Type A: (𝜆(𝑋:Sort 0). X) P(i.e., Id P )</li><li>Type B: P</li></ul><p>Syntactically, Type A is a function application while Type B is a single variable, so they are not alpha-equivalent. Yet if we actually <em>run</em> the function application in Type A by plugging P into X, it evaluates directly to P. Thus, any proof of Id Pmust also be accepted as a valid proof of P.</p><p>More formally, evaluating a function application by substituting the argument into the function body is called 𝛽-reduction:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*NbZukk8koggpg1U3Ibvi0A.png" /></figure><p>When we repeatedly apply 𝛽-reduction everywhere inside a term until no more function applications can be simplified, the resulting expression is in<em> normal form</em>.</p><pre>                Term::Lam(x, _, body) if !args.is_empty() =&gt; Some(apps(<br>                    subst(&amp;body, &amp;x, &amp;args[0]),<br>                    args[1..].iter().cloned(),<br>                )),</pre><h4>7.4 Conversion</h4><p>To decide 𝑠 ≡ 𝑡, the kernel normalizes both terms and compares the results with =𝛼 . This procedure relies on two facts. First, 𝛽-reduction is confluent: different reduction orders cannot lead to different normal forms, so 𝑠 ≡ 𝑡 holds exactly when the normal forms of 𝑠 and 𝑡 are 𝛼-equivalent. Second, every well-typed term has a normal form, so normalization terminates.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*L-YxSN3NCg6BbcwEQtUBug.png" /></figure><h3>8. Conclusion</h3><p>We started with a simple question: <em>What actually happens when Lean 4 verifies a mathematical proof?</em></p><p>By building a miniature theorem prover from scratch, we have seen that the answer is surprisingly elegant. At its core, a theorem prover is a type checker. Thanks to the Curry–Howard correspondence, propositions can be represented as types, proofs as programs, and proof verification as ordinary type checking.</p><p>Of course, MiniLean4 is far from a complete implementation of Lean 4. The real system includes sophisticated elaboration, universe polymorphism, type classes, powerful metaprogramming facilities, and many other features. Our implementation also deliberately simplifies several aspects of inductive types and tactics.</p><p>Nevertheless, the fundamental architecture is already there. Once you understand how a few typing rules, reduction rules, and recursors fit together, interactive theorem proving starts to look much less like magic and much more like a carefully designed programming language.</p><p>You can explore the complete implementation, run the examples, and experiment with your own extensions in the <a href="https://github.com/h5i-dev/MiniLean4">MiniLean4 GitHub repository</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a5d964d173ee" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[I Rebuilt an AI Gateway in Rust. It Became 41× Faster.]]></title>
            <link>https://medium.com/@Koukyosyumei/i-rebuilt-an-ai-gateway-in-rust-it-became-41-faster-47486b8f0d47?source=rss-43fb167fbb77------2</link>
            <guid isPermaLink="false">https://medium.com/p/47486b8f0d47</guid>
            <category><![CDATA[ai-gateway]]></category>
            <category><![CDATA[rust]]></category>
            <category><![CDATA[lean4]]></category>
            <category><![CDATA[formal-verification]]></category>
            <category><![CDATA[ai-agent]]></category>
            <dc:creator><![CDATA[Hideaki Takahashi]]></dc:creator>
            <pubDate>Thu, 29 Oct 2026 18:14:15 GMT</pubDate>
            <atom:updated>2026-10-29T18:14:15.609Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/690/0*_vuk_mjJMupyOsZc.png" /></figure><p><em>Reimplementing LiteLLM with Rust with h5i-app, optimizing streaming and database operations, and formally verifying core logic with Lean 4.</em></p><p>As applications increasingly rely on multiple LLM providers, developers need a way to manage API keys, route requests, enforce rate limits, track usage, and control costs through a unified interface. This is why AI gateways, services that unify various LLM inference APIs, are becoming an essential part of modern AI infrastructure.</p><p>One of the most popoular open-source AI gateways is <a href="https://github.com/BerriAI/litellm">LiteLLM</a>, a Python-based AI gateway that supports a wide range of LLM providers.</p><p>Last weekend, one simple question came to my mind: <em>“What if we rebuilt an AI gateway in Rust? How much performance could we gain?” </em>.</p><p>To find out, I built <a href="https://github.com/h5i-dev/h5i-ai-gateway">h5i-ai-gateway</a>, an open-source Rust implementation of an AI gateway inspired by LiteLLM, using the Rust web framework, <a href="https://github.com/h5i-dev/h5i">h5i-app</a> which is a Axum-based framework backed with Lean 4 formal verification.</p><p>The results was quite promising:</p><ul><li>41.4× higher throughput for standard chat completions</li><li>40.5× higher throughput for streaming responses</li><li>30.1× higher throughput for embeddings</li><li>65× lower memory usage, from 4.4 GB to just 68 MB</li><li>More than 100× improvement in p99 latency, from 346 ms to 3.08 ms</li></ul><p>h5i-app framework also alloed me to formally verify important properties like the correctness of authentication with Lean 4.</p><h3>What Is an AI Gateway?</h3><p>An AI gateway sits between your application and LLM providers such as OpenAI, Anthropic, and Google. Instead of integrating each provider independently, applications send requests through a single gateway.</p><pre>              Application<br>                   |<br>                   v<br>               AI Gateway<br>                   |<br>          +--------+--------+<br>          |        |        |<br>          v        v        v<br>        OpenAI  Anthropic  Gemini</pre><p>An AI gateway typically handles several responsibilities:</p><ul><li><em>Authentication</em>: Validate API keys and control access to models.</li><li><em>Routing</em>: Forward requests to the appropriate LLM provider.</li><li><em>Rate limiting</em>: Prevent excessive requests from individual users or teams.</li><li><em>Budget management</em>: Track token usage and enforce spending limits.</li><li><em>Streaming</em>: Relay responses using Server-Sent Events (SSE).</li></ul><p>While his additional infrastructure is worthwhile for many applications because it centralizes important operational concerns, the gateway itself can become a significant source of overhead as AI agents begin generating hundreds or thousands of LLM requests,</p><h3>(Re-) Building the Gateway in Rust</h3><p>I re-implemented the LiteLLM gateway in Rust, using <a href="https://github.com/h5i-dev/h5i">h5i-app</a>, a Rust web framework built on top of Axum.</p><p>One of the central design principles of h5i-app is to separate application logic from HTTP infrastructure. Instead of embedding authorization, budgeting, and routing decisions directly into HTTP handlers, the gateway implements these operations in a separate Rust kernel. This architecture also allows the core logic to be translated into Lean 4 for formal verification.</p><p>The overall structure looks like this:</p><pre>               HTTP Request<br>                   |<br>                   v<br>              Gateway Server<br>                   |<br>                   v<br>               Rust Kernel<br>                   |<br>            +------+------+<br>            |             |<br>          Admit         Settle<br>            |             ^<br>            v             |<br>        Provider API -----+<br>                   |<br>                   v<br>              HTTP Response</pre><p>Each request goes through two important stages.</p><p><strong>1. Admit: Validate and reserve</strong></p><p>Before forwarding a request, the gateway checks:</p><ul><li>Whether the API key is valid</li><li>Whether the requested model is allowed</li><li>Whether the request satisfies rate limits</li><li>Whether sufficient budget is available</li></ul><p>If the request is accepted, the gateway reserves the estimated cost.</p><p><strong>2. Settle: Finalize usage</strong></p><p>After the provider returns a response, the gateway calculates the actual cost using token usage information and updates the corresponding budget.</p><p>This separation makes it easier to reason about the correctness of accounting operations, especially when multiple requests execute concurrently. The gateway also supports chat completions, embeddings, streaming responses, and routing across multiple providers.</p><h3>Benchmark: Rust vs. Python LiteLLM</h3><p>To make the performance comparison meaningful, I wanted to measure the gateway itself rather than the speed of the underlying LLM. Specifically, I compared the original Python implementation of LiteLLM and my Rust re-implementation, using 8 CPUs and 64 concurrent connections.</p><h4>Throughput</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ajt1U_ju2_QlfJOuMeNy2g.png" /></figure><p>The Rust implementation achieved more than 40 times the throughput for both standard chat completions and streaming.</p><p>However, the PostgreSQL-backed workload showed a much smaller improvement, showing that a faster language does not automatically eliminate database bottlenecks.</p><h4>Memory Usage</h4><p>The difference in memory consumption was equally significant.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*f9xdLBAss--6tuxVu9wjHA.png" /></figure><p>The Rust implementation used approximately <strong>65 times less memory</strong> in this benchmark configuration. This can become particularly important when deploying gateways across multiple replicas or operating resource-constrained infrastructure.</p><h4>Latency</h4><p>For the chat completion benchmark, the latency results were:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*C8yZ8tZ3JGZhM6mvu7Mcfw.png" /></figure><p>The improvement in tail latency was particularly impressive. At p99, latency decreased from 346 ms to just 3.08 ms. Of course, real LLM requests often spend much longer waiting for inference. To explore this, I also benchmarked the gateways with an artificial 50 ms upstream delay.</p><p>At 256 concurrent connections:</p><ul><li>Python LiteLLM: <strong>657 req/s</strong></li><li>Rust gateway: <strong>4,887 req/s</strong></li></ul><p>The Rust implementation still achieved approximately 7.4× higher throughput. These results suggest that gateway overhead can remain significant even when upstream requests involve network delays.</p><h3>What Actually Made It Faster?</h3><p>The performance improvement was not simply the result of replacing Python with Rust, and several optimizations made a substantial difference.</p><h4>1. Optimizing TCP for Streaming Responses</h4><p>LLM streaming typically uses Server-Sent Events (SSE), where the server continuously sends small chunks of data to the client. Unlike a conventional HTTP response, which might return a single large payload, an SSE connection can involve many small writes. These writes can interact with TCP buffering and delayed acknowledgments, introducing unnecessary latency.</p><p>One important optimization was enabling TCP_NODELAY on accepted TCP sockets. This disables Nagle’s algorithm, allowing small TCP segments to be transmitted without waiting for additional data to accumulate.</p><p>In the streaming benchmark with 64 concurrent connections, enabling this option improved throughput from approximately: 1,550 req/s → 11,600 req/s (roughly a 7.5x improvement).</p><h4>2. Batching PostgreSQL Operations with Group Commit</h4><p>AI gateways often need to maintain accurate accounting information. For example, a request may reserve part of a user’s remaining budget before execution, then settle the final amount after the response is received.</p><p>In a PostgreSQL-backed implementation, these operations typically require transactions to preserve consistency. However, issuing an independent transaction for every individual update can become expensive under high concurrency.</p><p>To address this, I implemented Group Commit. Instead of committing every update independently, the gateway groups multiple updates from the same tenant into a batch. It also coalesces multiple modifications to the same database row when possible. This significantly reduces the overhead associated with database round trips and repeated updates.</p><p>With 64 concurrent connections, PostgreSQL-backed throughput improved from approximately: 70 req/s → 877 req/s (12.5× improvement) .</p><h3>Formal Verification with Lean 4</h3><p>Making infrastructure faster is useful, but an AI gateway also needs to be correct. This becomes especially important when the gateway handles authentication, authorization, and financial accounting.</p><p>For example, consider the following requirements:</p><ul><li>A request must not be admitted if it violates the budget policy.</li><li>An API key without administrative permissions must not be allowed to modify restricted limits.</li><li>An optimized batch operation must preserve the semantics of the corresponding sequential operations.</li></ul><p>These are not merely performance considerations. They are correctness properties. Traditional unit tests can check specific examples, but they cannot generally establish that a property holds for all possible inputs and states. This is where formal verification comes in.</p><h4>Rust Kernels and Lean 4</h4><p>One of the motivations behind <a href="https://github.com/h5i-dev/h5i">h5i-app</a> is to make formal verification more practical for real web applications. The framework encourages developers to implement important business logic as a separate Rust kernel. That kernel can then be translated into Lean 4, where mathematical properties of its behavior can be specified and proven.</p><p>For the AI gateway, this includes reasoning about admission decisions, permissions, and budget accounting. I also verified properties related to the batching optimization, including equivalence between optimized batched operations and their sequential counterparts under the modeled assumptions.</p><h3>Try It Yourself</h3><p>I have open-sourced both the AI gateway and its benchmark.</p><ul><li><strong>AI Gateway:</strong> <a href="https://github.com/h5i-dev/h5i-ai-gateway">h5i-dev/h5i-ai-gateway</a></li><li><strong>Benchmark Scripts and Results:</strong> <a href="https://github.com/h5i-dev/h5i-ai-gateway/tree/main/bench">h5i-ai-gateway/bench</a></li><li><strong>Rust Web Framework:</strong> <a href="https://github.com/h5i-dev/h5i">h5i-dev/h5i</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=47486b8f0d47" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[I Tried to Formally Verify Popular Rust Projects with Lean 4, but Found More Bugs Than I Expected]]></title>
            <link>https://medium.com/@Koukyosyumei/i-tried-to-formally-verify-popular-rust-projects-with-lean-4-but-found-more-bugs-than-i-expected-5badd0017b7c?source=rss-43fb167fbb77------2</link>
            <guid isPermaLink="false">https://medium.com/p/5badd0017b7c</guid>
            <category><![CDATA[web-development]]></category>
            <category><![CDATA[security]]></category>
            <category><![CDATA[formal-verification]]></category>
            <category><![CDATA[lean4]]></category>
            <category><![CDATA[rust]]></category>
            <dc:creator><![CDATA[Hideaki Takahashi]]></dc:creator>
            <pubDate>Mon, 19 Oct 2026 17:04:59 GMT</pubDate>
            <atom:updated>2026-10-19T19:08:17.223Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/690/0*54naorV6bXtc32EC.png" /></figure><p><em>I tried building a benchmark for formally verifying popular Rust OSS. Along the way, I ended up finding real security vulnerabilities.</em></p><p>In my <a href="https://medium.com/@Koukyosyumei/proving-rust-web-application-correctness-with-lean-4-8889583f1e15">previous article</a>, I introduced <a href="https://github.com/h5i-dev/h5i"><strong>h5i</strong></a>, an Axum-based Rust web framework that allows developers to translate application logic into Lean 4 and formally verify properties such as authorization, data isolation, and business rules.</p><p>The core idea of h5i is pretty simple: instead of relying on unit tests alone to check whether an application behaves correctly, we want to mathematically prove that real implementations satisfy specific properties. To achieve this, h5i adopts a kernel-based architecture that allows security-critical application logic to be automatically translated into Lean 4 (see my <a href="https://medium.com/@Koukyosyumei/proving-rust-web-application-correctness-with-lean-4-8889583f1e15">previous article</a> for details).</p><p>To find out whether this approach works for real-world systems, I started building <a href="https://github.com/h5i-dev/h5i-app-bench">h5i-app-bench</a>, a benchmark that takes existing Axum-based Rust open-source projects, ports their core implementations to h5i, and attempts to formally verify their correctness using Lean 4. Since h5i itself is built on Axum, porting existing Axum applications to h5i was relatively straightforward.</p><p>Although I was able to prove most of the selected security properties, I also encountered several properties that I couldn’t prove in Lean 4. Investigating these failed proof attempts revealed bugs in the original Rust implementations, which I then confirmed with concrete tests against the upstream projects.</p><p>Some turned out to be real security vulnerabilities, including cross-user data deletion, access-control bypasses, information disclosure, and denial of service in widely used open-source projects.</p><p>Here are a few findings that have already been fixed and publicly disclosed. Some of the corresponding benchmark cases, including RustiCal, are still being prepared and have not yet been published.</p><h4>1. RustiCal: One User Could Delete Another User’s Data</h4><p>The first example is <a href="https://github.com/lennart-k/rustical/security/advisories/GHSA-9cp5-7crm-9cf7">RustiCal (GHSA-9cp5–7crm-9cf7)</a>, a Rust-based CalDAV and CardDAV server.</p><p>The bug was surprisingly simple. RustiCal stores calendar objects using three identifiers:</p><pre>(principal, calendar_id, object_id)</pre><p>Here, principal identifies the owner. Suppose Alice and Bob both have a calendar named work, containing an event named meeting.ics. These are two completely different objects belonging to different users. However, the hard-delete path executed a database query equivalent to:</p><pre>DELETE FROM calendarobjects<br>WHERE cal_id = ? AND id = ?;</pre><p>Notice what is missing? <strong>The owner’s identity. </strong>The application correctly checked that Alice was authorized to delete her own event. But the resulting SQL operation could also delete Bob’s event if the calendar and object identifiers matched.</p><p>In a reproduction against the original application:</p><pre>Alice deletes Bob&#39;s event directly<br>    -&gt; 401 Unauthorized<br><br>Alice deletes her own matching event<br>    -&gt; 200 OK<br><br>Bob tries to read his event<br>    -&gt; 404 Not Found</pre><p>The authorization check worked at the HTTP layer, but the database operation affected a larger set of objects than the authorization decision permitted.</p><h4>2. OxiCloud: File Metadata Leaked Through Music Playlists</h4><p>The next example comes from <a href="https://github.com/AtalayaLabs/OxiCloud/pull/796">OxiCloud PR #796</a>, a Rust-based file management and sharing platform.</p><p>OxiCloud has an authorization system that controls which files a user can access. The interesting part was its music playlist functionality.</p><p>A user could create a playlist and add file identifiers to it. However, the application checked whether the user could modify the playlist without sufficiently checking whether the user could access the referenced files.</p><p>The resulting behavior was roughly:</p><pre>Alice owns a playlist<br>        |<br>        v<br>Alice adds Bob&#39;s file ID<br>        |<br>        v<br>Playlist accepts the reference<br>        |<br>        v<br>Alice requests playlist tracks<br>        |<br>        v<br>Bob&#39;s private file metadata is returned</pre><p>The leaked information included file names, sizes, MIME types, and audio metadata. The underlying issue was that permission to modify a playlist was being confused with permission to read the files referenced by that playlist.</p><h4>3. RustFS: A Policy Evaluation That Could Loop Forever</h4><p>Not every interesting finding involved authorization. While working with RustFS, an S3-compatible object storage system written in Rust, I also encountered a non-termination bug in its policy-variable resolution logic, which was published as <a href="https://github.com/rustfs/rustfs/security/advisories/GHSA-9wgh-5r68-49p7">GHSA-9wgh-5r68–49p7</a>.</p><p>Specifically, RustFS supports variables in access-control policies, such as:</p><pre>${aws:AccountId}<br>${aws:username}</pre><p>These variables are substituted with values from the request context.</p><p>The implementation already had a maximum iteration count intended to prevent unbounded expansion. However, the limit was enforced only in the outer resolution loop. A nested substitution routine could repeatedly reconstruct a variable expression and expand it again without returning to that outer loop.</p><p>As a result, a tiny input could trigger more than 100,000 resolver calls, while an ordinary input required just one. In certain configurations, this could lead to a denial of service.</p><h4>4. Why Did Building a Verification Benchmark Reveal Bugs?</h4><p>One lesson from this experiment was that formal verification changes how we inspect software.</p><p>When conducting ordinary security testing, we often start by looking for suspicious endpoints, missing checks, or known vulnerability patterns.</p><p>When formalizing an application, we start with a different question: <strong>What property is this code supposed to guarantee?</strong></p><p>For example:</p><ul><li>Can one user ever modify another user’s data?</li><li>Does every successful operation satisfy the declared access policy?</li><li>Can an API response reveal an object that the caller cannot read?</li><li>Does a recursive or iterative operation always terminate?</li></ul><p>To prove these properties, we need to understand the relevant behavior precisely. That process often exposes assumptions that were never explicitly enforced.</p><ul><li>In RustiCal, authorization was checked, but the final database operation was insufficiently scoped.</li><li>In OxiCloud, playlist permissions did not imply permissions on the referenced files.</li><li>In RustFS, an iteration bound did not actually bound the inner computation.</li></ul><p>Of course, an unsuccessful proof is not automatically evidence of a vulnerability. It may simply mean that the proof is incomplete, the model is incorrect, or the translation does not support some language feature.</p><p>But when a property cannot be proved, investigating why can sometimes reveal a real bug. And that is exactly what made this experiment interesting.</p><h4>5. What’s Next?</h4><p>I am continuing to develop <a href="https://github.com/h5i-dev/h5i-app-bench">h5i-app-bench</a> to understand how formal verification with Lean 4 can scale to real Rust applications.</p><p>The benchmark currently includes six published application ports and 101 selected specifications, covering real-world authorization, identity management, file sharing, policy evaluation, and event visibility logic.</p><p>AI coding agents are making it dramatically easier to generate large amounts of application code. But generating more code does not automatically make it correct or secure.</p><p>We need better ways to check what that code actually guarantees. My original goal was simply to benchmark a framework for proving Rust application correctness. But along the way, I learned that formal verification isn’t just about proving software correct. <strong>It can also help us discover where our assumptions about correctness break down.</strong></p><p><strong>Links</strong></p><ul><li><a href="https://github.com/h5i-dev/h5i">h5i — Build, Verify, and Hack Web Applications</a></li><li><a href="https://github.com/h5i-dev/h5i-app-bench">h5i-app-bench — Formal Verification Benchmark</a></li><li><a href="https://benchmark.h5i.dev/prove/">Benchmark Dashboard</a></li><li><a href="https://medium.com/@Koukyosyumei/proving-rust-web-application-correctness-with-lean-4-8889583f1e15">Previous Article: Proving Rust Web Application Correctness with Lean 4</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5badd0017b7c" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Proving Rust Web Application Correctness with Lean 4]]></title>
            <link>https://medium.com/@Koukyosyumei/proving-rust-web-application-correctness-with-lean-4-8889583f1e15?source=rss-43fb167fbb77------2</link>
            <guid isPermaLink="false">https://medium.com/p/8889583f1e15</guid>
            <category><![CDATA[formal-verification]]></category>
            <category><![CDATA[lean4]]></category>
            <category><![CDATA[security]]></category>
            <category><![CDATA[web-development]]></category>
            <category><![CDATA[rust]]></category>
            <dc:creator><![CDATA[Hideaki Takahashi]]></dc:creator>
            <pubDate>Tue, 06 Oct 2026 16:11:55 GMT</pubDate>
            <atom:updated>2026-10-06T16:11:55.596Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/690/0*sc1Bcjg5o_sCy62L.png" /></figure><p>AI coding agents such as Claude Code and Codex are making it possible to build surprisingly large web applications in a very short amount of time.</p><p>But as the speed of writing code increases, another problem becomes increasingly important: “<em>How do we know that the generated code actually behaves the way we intended?</em><strong>”</strong></p><p>Of course, we can write unit tests. But tests usually tell us something like <em>“For the inputs we tried, the program behaved correctly.”</em></p><p>Sometimes, however, we want much stronger guarantees like</p><ul><li>One user must never be able to modify another user’s data.</li><li>A balance must never become negative.</li><li>An unauthorized user must never be able to perform an administrative operation.</li><li>A database invariant must remain true after every valid operation.</li></ul><p>These are properties we would ideally like to guarantee for <strong>all possible inputs</strong>, rather than for a finite collection of test cases.</p><p>This is where <strong>formal verification</strong> becomes useful. In this article, I’ll show how we can write the core logic of a web application in Rust, automatically translate that logic into Lean 4, and prove properties about the actual program.</p><p>We’ll use <strong>h5i-app</strong>, a Rust framework built as part of the <a href="https://github.com/h5i-dev/h5i">h5i</a> web security project (repository: <a href="https://github.com/h5i-dev/h5i">https://github.com/h5i-dev/h5i</a>).</p><h3>What Is Lean 4?</h3><p><a href="https://lean-lang.org/">Lean 4</a> is both a programming language and a interactive proof assistan systemt, where we can write mathematical statements and machine-checkable proofs.</p><p>Suppose we are testing a simple function. With ordinary tests, we might check examples such as:</p><pre>1 + 1 = 2<br>100 + 1 = 101</pre><p>Lean lets us state something much stronger:</p><pre>For every natural number n,<br>n + 1 is greater than n.</pre><p>Instead of checking a handful of concrete values, we state a proposition that quantifies over every possible value, and Lean verifies that the proof is logically correct.</p><p>For a web application, we might want to prove something like:</p><pre>For every pair of users A and B,<br>an operation performed by A cannot modify B&#39;s data.</pre><p>If Lean’s kernel accepts the proof, then, under the assumptions encoded in the model , that property has been mechanically verified.</p><p>However, one of the practical problems is how we can ensure the equivalence between the actual Rust implementatino and Lean4 model. To reduce this gap, h5i-app uses <a href="https://github.com/AeneasVerif/aeneas">Aeneas</a>, which translates Rust programs into Lean.</p><p>The entire workflow therefore looks like this:</p><pre>Write application logic in Rust<br>            ↓<br>Automatically translate it to Lean 4<br>            ↓<br>State the desired properties as theorems<br>            ↓<br>Prove them in Lean</pre><p>The important point is that the proof is connected to the Rust implementation itself, rather than to a separately maintained model.</p><h3>What Is h5i-app?</h3><p>h5i-app is part of <a href="https://github.com/h5i-dev/h5i">h5i</a>, an agent-native web security workspace. h5i approaches web security from two complementary directions; 1) find bugs like a professional red-team, and 2) prove that properties via formal verification.</p><p>h5i-app handles the second part, is designed around Axum, which is one of the most popular web framework in Rust, and lets developers isolate security-critical application logic in Rust and verify that logic in Lean 4.</p><p>The goal is <strong>not</strong> to rewrite an entire web application in Lean. You still write your application in Rust as usual. Instead, you separate out the parts where correctness matters most , authorization, state transitions, business rules, and database writes, and make those parts amenable to verification.</p><p>Conceptually, the architecture looks like this:</p><pre>HTTP request<br>     │<br>     ▼<br>┌──────────────┐<br>│ Axum / HTTP  │<br>│  transport   │<br>└──────┬───────┘<br>       │<br>       ▼<br>┌──────────────────────┐<br>│     Rust kernel      │<br>│                      │<br>│ authorization        │<br>│ business logic       │<br>│ state transitions    │<br>└──────┬───────────────┘<br>       │<br>       │ Aeneas<br>       ▼<br>┌──────────────────────┐<br>│        Lean 4        │<br>│                      │<br>│ theorem / proof      │<br>└──────────────────────┘<br>       │<br>       ▼<br>┌──────────────────────┐<br>│ storage / PostgreSQL │<br>└──────────────────────┘</pre><p>The central idea is the kernel that receives:</p><pre>current state<br>+<br>the user performing the operation<br>+<br>the requested operation</pre><p>and decides:</p><pre>allow / reject<br>+<br>new state<br>+<br>response</pre><p>Because the kernel is written as relatively pure Rust, separated from HTTP and SQL, Aeneas can translate it into Lean.</p><p>Typical things worth verifying include:</p><ul><li>application state transitions</li><li>authorization rules</li><li>business invariants</li><li>construction of database writes</li><li>correspondence between application snapshots and database representations</li></ul><h3>Building a Tiny Verified Web Application</h3><p>Let’s build a small example. We’ll create a very simple application that behaves like calculator memory.</p><p>Each user owns one number. A user can:</p><ul><li>set the number,</li><li>apply arithmetic operations to it,</li><li>or read the current value.</li></ul><p>Create the project:</p><pre>h5i app new calculator<br>cd calculator-01</pre><p>The generated structure looks roughly like this:</p><pre>calculator/<br>├── h5i-app.toml<br>├── kernel/<br>│   └── src/<br>│       └── lib.rs<br>└── proofs/<br>    ├── Theorems.lean<br>    └── generated/</pre><p>The important parts are:</p><pre>kernel/src/lib.rs<br>    Application logic written in Rust<br>proofs/Theorems.lean<br>    Properties we want to prove<br>proofs/generated/<br>    Lean code automatically generated from Rust<br>h5i-app.toml<br>    Extraction, verification, and mutation-testing configuration</pre><h4>Step 1: Define Who Is Performing the Operation</h4><p>First, modify kernel/src/lib.rs, and define a Principal representing the authenticated user:</p><pre>#[derive(Clone, Copy, Debug, PartialEq, Eq)]<br>pub struct Principal {<br>    pub org: u64,<br>    pub user: u64,<br>}</pre><p>Here we can interpret that org is the organization ID, and user is the user ID inside that organization..</p><h4>Step 2: Define the Application State</h4><p>Next, we define the data stored by the application. h5i-app provides a schema! macro that describes the schema in a form shared between Rust, Lean, and PostgreSQL.</p><pre>h5i_app_schema::schema! {<br>    mapping calc_tables for calculator_kernel,<br>        lean &quot;../proofs/generated/Schema.lean&quot;;<br><br>    #[derive(Clone, Debug, Default, PartialEq, Eq)]<br>    pub struct Snapshot {<br>        memories: Vec&lt;Memory&gt;,<br>    }<br>    #[derive(Clone, Copy, Debug, PartialEq, Eq)]<br>    pub struct Memory in &quot;memories&quot; {<br>        key { user: u64 }<br>        value: u64<br>    }<br>}</pre><p>You can think of Snapshot as the current relevant database state. Each Memory represents one user’s calculator value. The schema definition is also used to generate machinery for things such as:</p><pre>Rust row operations<br>database conversions<br>SQL write representations<br>Lean schema definitions</pre><p>This is useful because otherwise we would have to maintain separate descriptions of the data model for Rust, SQL, and Lean.</p><h4>Step 3: Put the Application Decisions in the Kernel</h4><p>Now we write the core application logic. Conceptually, the kernel exposes a function like:</p><pre>fn transition(<br>    actor: &amp;Principal,<br>    snap: &amp;Snapshot,<br>    cmd: &amp;Command,<br>) -&gt; Result&lt;(WriteSet, Reply), Error&gt;</pre><p>It receives:</p><pre>who is acting<br>+<br>the current state<br>+<br>what they want to do</pre><p>and returns:</p><pre>allowed:<br>    database changes<br>    +<br>    response</pre><p>For our calculator application:</p><pre>pub fn transition(<br>    actor: &amp;Principal,<br>    snap: &amp;Snapshot,<br>    cmd: &amp;Command,<br>) -&gt; Result&lt;(Option&lt;Memory&gt;, Reply), Error&gt; {<br>    match cmd {<br>        Command::Set { value } =&gt; Ok((<br>            Some(Memory {<br>                user: actor.user,<br>                value: *value,<br>            }),<br>            Reply::Value(*value),<br>        )),<br><br>　　　　　Command::Apply { op, arg } =&gt; {<br>            let m = memory_of(&amp;snap.memories, actor.user);<br>            match compute(*op, m, *arg) {<br>                Ok(v) =&gt; Ok((<br>                    Some(Memory {<br>                        user: actor.user,<br>                        value: v,<br>                    }),<br>                    Reply::Value(v),<br>                )),<br>                Err(e) =&gt; Err(e),<br>            }<br>        }<br>        Command::Get =&gt; Ok((<br>            None,<br>            Reply::Value(memory_of(&amp;snap.memories, actor.user)),<br>        )),<br>    }<br>}</pre><p>The actual arithmetic lives in compute:</p><pre>pub fn compute(<br>    op: Op,<br>    a: u64,<br>    b: u64,<br>) -&gt; Result&lt;u64, Error&gt; {<br>    match op {<br>        Op::Add =&gt; {<br>            if a &gt; u64::MAX - b {<br>                Err(Error::Overflow)<br>            } else {<br>                Ok(a + b)<br>            }<br>        }<br><br>　　　　　Op::Sub =&gt; {<br>            if a &lt; b {<br>                Err(Error::Underflow)<br>            } else {<br>                Ok(a - b)<br>            }<br>        }<br>        Op::Mul =&gt; {<br>            if b != 0 &amp;&amp; a &gt; u64::MAX / b {<br>                Err(Error::Overflow)<br>            } else {<br>                Ok(a * b)<br>            }<br>        }<br>        Op::Div =&gt; {<br>            if b == 0 {<br>                Err(Error::DivByZero)<br>            } else {<br>                Ok(a / b)<br>            }<br>        }<br>    }<br>}</pre><p>Notice that this is still completely ordinary Rust. We have not written any Lean yet.</p><h4>Step 4: Connect the Kernel to the Web Server</h4><p>The kernel itself does not know anything about HTTP. On the server side, we connect it to h5i-app’s Kernel interface:</p><pre>pub struct Calc;<br><br>impl Kernel for Calc {<br>    type Principal = k::Principal;<br>    type Snapshot = k::Snapshot;<br>    type Command = k::Command;<br>    type WriteSet = Option&lt;k::Memory&gt;;<br>    type Reply = k::Reply;<br>    type Error = k::Error;<br>    fn tenant(actor: &amp;k::Principal) -&gt; TenantId {<br>        TenantId(actor.org)<br>    }<br>    fn transition(<br>        actor: &amp;k::Principal,<br>        snap: &amp;k::Snapshot,<br>        cmd: &amp;k::Command,<br>    ) -&gt; Result&lt;(Option&lt;k::Memory&gt;, k::Reply), k::Error&gt; {<br>        k::transition(actor, snap, cmd)<br>    }<br>    fn apply(<br>        snap: &amp;k::Snapshot,<br>        w: &amp;Option&lt;k::Memory&gt;,<br>    ) -&gt; k::Snapshot {<br>        k::apply(snap, w)<br>    }<br>}</pre><p>This may look complicated at first, but it is mostly wiring:</p><pre>Axum<br> ↓<br>h5i-app Engine<br> ↓<br>Calc::transition<br> ↓<br>calculator_kernel::transition</pre><p>When a request is processed, the runtime roughly performs the following sequence:</p><pre>HTTP request<br>     │<br>     ▼<br>Authenticate the user<br>     │<br>     ▼<br>Begin PostgreSQL transaction<br>     │<br>     ▼<br>Load the tenant state as a Snapshot<br>     │<br>     ▼<br>kernel::transition(actor, snapshot, command)<br>     │<br>     ├── Error<br>     │     └── rollback<br>     │<br>     └── Success<br>           │<br>           ▼<br>       apply writes<br>           │<br>           ▼<br>         commit</pre><p>The important security decisions therefore happen inside a small piece of logic that we can reason about independently.</p><h4>Step 5: Translate the Rust Kernel to Lean</h4><p>Now comes the interesting part. We want to prove properties about the transition function that the application is actually running.</p><p>Run:</p><pre>h5i app extract</pre><p>h5i-app invokes Aeneas and generates Lean code under proofs/generated . We now have a Lean representation derived from our Rust implementation.</p><h4>Step 6: Define the Lean4 specifications.</h4><p>Formal verification does not magically determine what your application <em>should</em> do. You still need to specify the desired properties. That is actually one of the most important aspects of formal verification.</p><p>For example, we can state that the transition function is total: for every actor, application state, and command, evaluating the kernel returns a well-defined result instead of encountering an invalid execution such as an unchecked overflow or bad index.</p><p>A theorem may look like this:</p><pre>/--<br>The kernel never fails:<br>no panic, overflow or bad index, for any input.<br>-/<br>theorem transition_total<br>    (a : Principal)<br>    (s : Snapshot)<br>    (c : Command) :<br>    ∃ r, transition a s c = ok r := by<br>  ...</pre><p>Here, variables like Principle, Snapshot, and Command, are arbitrary, and we are claiming something about every possible combination of actor, state, and command.</p><h4>Step 7: Prove It</h4><p>We then provide the proof:</p><pre>theorem transition_total<br>    (a : Principal)<br>    (s : Snapshot)<br>    (c : Command) :<br>    ∃ r, transition a s c = ok r := by<br>  have h : transition a s c ⦃ _ =&gt; True ⦄ := by<br>    unfold transition<br>    cases c &lt;;&gt; step*<br>  obtain ⟨r, hr, _⟩ :=<br>    (WP.spec_equiv_exists _ _).1 h<br>  exact ⟨r, hr⟩</pre><p>Finally:</p><pre>h5i app check</pre><p>Or, to re-extract the Rust code and verify everything together:</p><pre>h5i app prove</pre><p>If verification succeeds, the pipeline has checked:</p><pre>Rust kernel<br>    ↓<br>translation to Lean via Aeneas<br>    ↓<br>the theorem you specified<br>    ↓<br>proof checking by the Lean kernel</pre><h3>What Kinds of Properties Can We Prove?</h3><p>h5i-app works particularly well for properties that can be expressed in terms of <strong>state transitions</strong>.</p><h4>Authorization</h4><p>For example:</p><pre>A user from another organization<br>cannot modify this resource.</pre><p>This is stronger than testing a few authorization endpoints. The theorem can quantify over arbitrary users, organizations, resources, and application states.</p><h4>Data invariants</h4><p>Suppose an application handles balances. We could prove:</p><pre>If the balance is non-negative before an operation,<br>it remains non-negative afterward.</pre><h4>Workflow rules</h4><p>For a payment workflow:</p><pre>A request that has not been approved<br>cannot transition to the paid state.</pre><h4>Ownership</h4><p>For a resource-transfer system:</p><pre>resource.owner may change only when<br>a transfer operation succeeds.</pre><p>These are exactly the kinds of properties where ordinary test suites tend to grow into enormous collections of edge cases.</p><p>Formal verification lets us instead reason about the underlying rule.</p><h3>Tests and Formal Verification Solve Different Problems</h3><p>This does not mean formal verification replaces ordinary testing. A unit test is excellent for checking <em>“given this concrete input, do I get this concrete output?”</em></p><p>A theorem is better suited to questions like <em>“for every possible user, state, and command, can this security invariant ever be violated?”</em></p><p>Similarly, formal verification does not eliminate the need for security testing. You can only prove properties that you thought to specify. An attacker, on the other hand, may discover an entirely different weakness that never appeared in your specification.</p><h3>Conclusion: Why This Matters More in the Age of AI-Generated Code</h3><p>As AI agents become capable of generating increasingly large amounts of production code, reviewing every generated line manually becomes less realistic.</p><p>Today, we often rely on arguments such as “<em>I read the code and it looks correct.” or “The tests pass, so it is probably fine.” </em>Those approaches remain useful, but they become increasingly difficult to scale as the amount of generated software grows.</p><p>For important properties, I think we will increasingly want a stronger statement: <em>“Lean checked that this property holds for every possible input covered by the model.”</em></p><p>That does not mean the entire web application has somehow been mathematically proven perfect. The assumptions still matter., the specification still matters, and the boundary between the verified kernel and the rest of the system still matters. But it gives us a much stronger foundation for reasoning about particularly security-sensitive core application logic.</p><p>If you want to experiment with this approach, h5i is available on GitHub: <a href="https://github.com/h5i-dev/h5i"><strong>https://github.com/h5i-dev/h5i</strong></a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=8889583f1e15" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[i5h: Build a Rust Web App and Prove Its Behavior with Lean 4]]></title>
            <link>https://medium.com/@Koukyosyumei/i5h-build-a-rust-web-app-and-prove-its-behavior-with-lean-4-d80f2e4f0ed1?source=rss-43fb167fbb77------2</link>
            <guid isPermaLink="false">https://medium.com/p/d80f2e4f0ed1</guid>
            <category><![CDATA[rust]]></category>
            <category><![CDATA[web-development]]></category>
            <category><![CDATA[security]]></category>
            <category><![CDATA[web-application-security]]></category>
            <category><![CDATA[formal-verification]]></category>
            <dc:creator><![CDATA[Hideaki Takahashi]]></dc:creator>
            <pubDate>Tue, 29 Sep 2026 14:29:59 GMT</pubDate>
            <atom:updated>2026-10-06T16:12:50.543Z</atom:updated>
            <content:encoded><![CDATA[<p>&gt; NOTE!! i5h is now part of h5i, and the latest tutorial is available at <a href="https://medium.com/@Koukyosyumei/proving-rust-web-application-correctness-with-lean-4-8889583f1e15">https://medium.com/@Koukyosyumei/proving-rust-web-application-correctness-with-lean-4-8889583f1e15</a></p><p><em>A Formally verify the correctness of Web App </em>written in Axum via Lean4<em>.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/690/1*uHdsNmquQ0ts5XUvnjG17w.png" /></figure><p>AI Agents such as Claude Code and Codex has made it easy for anybody to implement web applications that “work”. However, it’s still quite difficult to verify whether those softwares are correctly implemented or not.</p><p>One potential approach is writting as many unit tests as possible and increasing their coeverage, though it does not provide any theoretical gurantee.</p><p>One of the better solution that gives us formally verified gurantee is using a theorme prover like Lean 4, where we can state a property such as “a successful command leaves every other user’s memory unchanged” and supply a mathematical proof checked by Lean.</p><p>However, verifying the correctness of Web Applications with Lean 4 is still quite difficult, since Lean 4 has been mostly used in mathematical research or softwares in limited domains like crypto, and there is no standard way to model basic comonents of web app like authentication, DB, or resource management.</p><p>This is why I’m building <a href="https://github.com/h5i-dev/i5h">i5h</a>, pronounced “icefish.” It lets developers prove properties of their Rust application logic in Lean 4 while serving the application through Axum. This walkthrough follows its <a href="https://github.com/h5i-dev/i5h/tree/main/examples/tutorials/calculator">calculator example</a>. The code excerpts come from that example; the complete project includes the imports, adapters, and proof bodies omitted here.</p><h3>Example: Calculator</h3><p>Let’s build a web API for a calculator with memory. Each user gets one stored number, initially zero. They can set it, apply an arithmetic operation, or read it back. The server saves the numbers in PostgreSQL between requests.</p><p>Here is how the finished API will behave. Alice and Bob are two users in the same organization. $ALICE and $BOB hold their respective authentication tokens, so the server can tell who is making each request. The example below shows the expected responses from a fresh database.</p><p>The helper sends a JSON command to POST /rpc:</p><pre>rpc() {<br>  curl -s \<br>    -H &quot;Authorization: Bearer $1&quot; \<br>    -H &#39;content-type: application/json&#39; \<br>    -d &quot;$2&quot; http://127.0.0.1:8080/rpc<br>  echo<br>}<br><br>rpc &quot;$ALICE&quot; &#39;{&quot;cmd&quot;:&quot;set&quot;,&quot;value&quot;:6}&#39;<br># {&quot;value&quot;:6}<br>rpc &quot;$ALICE&quot; &#39;{&quot;cmd&quot;:&quot;apply&quot;,&quot;op&quot;:&quot;mul&quot;,&quot;arg&quot;:7}&#39;<br># {&quot;value&quot;:42}<br>rpc &quot;$ALICE&quot; &#39;{&quot;cmd&quot;:&quot;get&quot;}&#39;<br># {&quot;value&quot;:42}<br>rpc &quot;$BOB&quot; &#39;{&quot;cmd&quot;:&quot;get&quot;}&#39;<br># {&quot;value&quot;:0}</pre><p>Alice stores 6, multiplies it by 7, and reads back 42. Bob still reads 0 because Alice’s commands affect only her own stored number. The token selects the caller; the JSON body selects the operation.</p><p>We want to establish three properties of this behavior: arithmetic produces the specified result or error, a successful update can be read back, and a user’s command leaves other users’ numbers unchanged.</p><h4>Axum handles HTTP; Lean checks proofs</h4><p>We will implement the calculator in Rust and then examine the Lean theorems that express these properties.</p><p><a href="https://docs.rs/axum/latest/axum/">Axum</a> provides HTTP routing and request handling for Rust applications. We use it to expose the calculator as a JSON API.</p><p><a href="https://github.com/AeneasVerif/aeneas">Aeneas</a> connects Rust and Lean 4. Together with Charon, it translates supported Rust code into Lean definitions. We then prove theorems about those extracted definitions.</p><p>To make this practical, i5h separates application decisions from I/O. A pure Rust function decides what a command does. The surrounding server loads data, calls the function, and persists the result.</p><h4>Define the calculator’s state and commands</h4><p>The authentication tokens in the opening example identify Alice and Bob. Inside the application, that authenticated identity is represented by a Principal: an organization ID plus a user ID. We will use organization 1, with user 1 for Alice and user 2 for Bob.</p><p>An organization is called a <strong>tenant</strong> in i5h. It is a group whose stored data is kept separate from other groups. Alice and Bob belong to the same tenant but have separate calculator memories within it.</p><p>Start in <a href="https://github.com/h5i-dev/i5h/blob/main/examples/tutorials/calculator/kernel/src/lib.rs">kernel/src/lib.rs</a>:</p><pre>#[derive(Clone, Copy, Debug, PartialEq, Eq)]<br>pub struct Principal {<br>    pub org: u64,<br>    pub user: u64,<br>}</pre><p>For Alice, this value is Principal { org: 1, user: 1 }; for Bob, it is Principal { org: 1, user: 2 }.</p><p>The <strong>kernel</strong> is the Rust code that decides what a command does. It receives a <strong>snapshot</strong>, the loaded calculator rows for the caller’s organization. In our example, those rows contain Alice’s and Bob’s stored numbers. We declare the snapshot and table together:</p><pre>i5h_schema::schema! {<br>    mapping calc_tables for calculator_kernel, lean &quot;../proofs/generated/Schema.lean&quot;;<br><br>/// One tenant&#39;s state: every user&#39;s memory.<br>    #[derive(Clone, Debug, Default, PartialEq, Eq)]<br>    pub struct Snapshot {<br>        memories: Vec&lt;Memory&gt;,<br>    }<br>    #[derive(Clone, Copy, Debug, PartialEq, Eq)]<br>    pub struct Memory in &quot;memories&quot; {<br>        key { user: u64 }<br>        value: u64,<br>    }<br>}</pre><p>A Memory row holds one user&#39;s stored number as an unsigned 64-bit value, keyed by that user&#39;s ID. schema! generates the row operations and table mapping, as well as a Lean schema file used by the proofs.</p><p>The API supports three commands:</p><pre>#[derive(Clone, Copy, Debug, PartialEq, Eq)]<br>pub enum Op {<br>    Add,<br>    Sub,<br>    Mul,<br>    Div,<br>}<br><br>#[derive(Clone, Copy, Debug, PartialEq, Eq)]<br>pub enum Command {<br>    /// Store `value` in my memory.<br>    Set { value: u64 },<br>    /// Replace my memory `m` with `m op arg`.<br>    Apply { op: Op, arg: u64 },<br>    /// Read my memory.<br>    Get,<br>}<br>#[derive(Clone, Copy, Debug, PartialEq, Eq)]<br>pub enum Reply {<br>    Value(u64),<br>}<br>#[derive(Clone, Copy, Debug, PartialEq, Eq)]<br>pub enum Error {<br>    Overflow,<br>    Underflow,<br>    DivByZero,<br>}</pre><p>Set replaces the caller&#39;s value, Apply performs arithmetic on it, and Get reads it. A user who has never stored a value starts at zero. The helper memory_of implements that rule by searching the snapshot for the first matching user row.</p><h4>Implement arithmetic and the state transition</h4><p>The arithmetic function computes the operations, while checking whether an operation has a valid u64 result:</p><pre>pub fn compute(op: Op, a: u64, b: u64) -&gt; Result&lt;u64, Error&gt; {<br>    match op {<br>        Op::Add =&gt; {<br>            if a &gt; u64::MAX - b {<br>                Err(Error::Overflow)<br>            } else {<br>                Ok(a + b)<br>            }<br>        }<br>        Op::Sub =&gt; {<br>            if a &lt; b {<br>                Err(Error::Underflow)<br>            } else {<br>                Ok(a - b)<br>            }<br>        }<br>        Op::Mul =&gt; {<br>            if b != 0 &amp;&amp; a &gt; u64::MAX / b {<br>                Err(Error::Overflow)<br>            } else {<br>                Ok(a * b)<br>            }<br>        }<br>        Op::Div =&gt; {<br>            if b == 0 {<br>                Err(Error::DivByZero)<br>            } else {<br>                Ok(a / b)<br>            }<br>        }<br>    }<br>}</pre><p>For multiplication, the guard checks whether the result would exceed u64::MAX without first computing the potentially overflowing product. Subtraction rejects a negative result, and division uses integer division.</p><p>Now connect arithmetic to the user’s memory:</p><pre>pub fn transition(<br>    actor: &amp;Principal,<br>    snap: &amp;Snapshot,<br>    cmd: &amp;Command,<br>) -&gt; Result&lt;(Option&lt;Memory&gt;, Reply), Error&gt; {<br>    match cmd {<br>        Command::Set { value } =&gt; Ok((<br>            Some(Memory { user: actor.user, value: *value }),<br>            Reply::Value(*value),<br>        )),<br>        Command::Apply { op, arg } =&gt; {<br>            let m = memory_of(&amp;snap.memories, actor.user);<br>            match compute(*op, m, *arg) {<br>                Ok(v) =&gt; Ok((<br>                    Some(Memory { user: actor.user, value: v }),<br>                    Reply::Value(v),<br>                )),<br>                Err(e) =&gt; Err(e),<br>            }<br>        }<br>        Command::Get =&gt; Ok((<br>            None,<br>            Reply::Value(memory_of(&amp;snap.memories, actor.user)),<br>        )),<br>    }<br>}</pre><p>The result contains a proposed write and a reply. Some(Memory { ... }) means to insert or replace a row; None means there is no write. An error refuses the command.</p><p>Notice that both write-producing branches construct the row using actor.user. The request does not supply a different user to write to. We will make this ownership property explicit in the proofs.</p><p>The function apply defines what applying a proposed write means for an in-memory snapshot:</p><pre>pub fn apply(snap: &amp;Snapshot, w: &amp;Option&lt;Memory&gt;) -&gt; Snapshot {<br>    let mut s = snap.clone();<br>    match w {<br>        Some(m) =&gt; Memory::put(&amp;mut s.memories, *m),<br>        None =&gt; {}<br>    }<br>    s<br>}</pre><p>The generated Memory::put replaces the matching user&#39;s row or appends a new one. Keeping this operation explicit lets us prove properties involving both the decision and its resulting state.</p><p>None of these functions queries PostgreSQL. Their inputs contain everything they need. The kernel uses the Rust subset supported by the extraction toolchain; the example uses indexed while loops and explicit match expressions.</p><h4>Expose the calculator through Axum</h4><p>The server adapter in <a href="https://github.com/h5i-dev/i5h/blob/main/examples/tutorials/calculator/server/src/lib.rs">server/src/lib.rs</a> connects the kernel to i5h:</p><ul><li><strong>Kernel for Calc</strong> supplies the types, tenant ID, transition, and apply.</li><li><strong>Store&lt;Calc&gt; for CalcStore</strong> loads rows and persists the kernel&#39;s write set through the generated mapping.</li><li><strong>Api&lt;Calc&gt; for CalcStore</strong> converts between JSON and kernel commands, replies, and errors.</li><li><strong>ReplyCodec&lt;Calc&gt; for CalcStore</strong> supplies encoding and request identity for idempotency support.</li></ul><p>For example, the kernel adapter calls the function we just wrote:</p><pre>fn transition(<br>    actor: &amp;k::Principal,<br>    snap: &amp;k::Snapshot,<br>    cmd: &amp;k::Command,<br>) -&gt; Result&lt;(Option&lt;k::Memory&gt;, k::Reply), k::Error&gt; {<br>    k::transition(actor, snap, cmd)<br>}</pre><p>The server’s main function constructs the engine and merges i5h&#39;s RPC route into an Axum router. Here is its central setup, with formatting expanded:</p><pre>let engine = Arc::new(Engine::&lt;Calc, CalcStore&gt;::new(<br>    pool(&amp;i5h_pg::with_schema(&amp;url, &quot;calculator&quot;)?, 8)?,<br>    EngineConfig::default(),<br>));<br>engine.install_schema().await?;<br><br>let app = I5h::new(engine, auth);<br>let router = Router::new()<br>    .route(&quot;/healthz&quot;, get(|| async { &quot;ok&quot; }))<br>    .merge(rpc_router(app));<br>let listener = tokio::net::TcpListener::bind(&amp;addr).await?;<br>axum::serve(listener, router).await?;</pre><p>For an RPC request, the engine loads the tenant’s state, runs transition, persists the resulting writes, and commits within a PostgreSQL SERIALIZABLE transaction. A retry starts again from a newly loaded snapshot.</p><h4>Extract the Rust code and write its specification</h4><p>Charon extracts an intermediate representation from the Rust kernel, and Aeneas translates it into proofs/generated/CalculatorKernel.lean. We do not edit that generated file. It represents the implementation whose behavior we want to establish.</p><p>Our specification lives separately in <a href="https://github.com/h5i-dev/i5h/blob/main/examples/tutorials/calculator/proofs/Spec.lean">proofs/Spec.lean</a>:</p><pre>def eval : Op → Nat → Nat → Except Error Nat<br>  | .Add, a, b =&gt; if a + b &lt; 2 ^ 64 then .ok (a + b) else .error .Overflow<br>  | .Sub, a, b =&gt; if b ≤ a then .ok (a - b) else .error .Underflow<br>  | .Mul, a, b =&gt; if a * b &lt; 2 ^ 64 then .ok (a * b) else .error .Overflow<br>  | .Div, a, b =&gt; if b = 0 then .error .DivByZero else .ok (a / b)</pre><p>Nat means natural numbers, so multiplication here has no machine-integer overflow. The specification computes the mathematical result and checks whether it fits below 2^64.</p><p>That gives us two independently readable descriptions. Rust implements multiplication using a division-based overflow guard. Lean’s eval describes the intended answer directly. The proof must show that they agree, including which operations return errors.</p><h3>State what the implementation must guarantee</h3><p>The following declarations are from <a href="https://github.com/h5i-dev/i5h/blob/main/examples/tutorials/calculator/proofs/Proofs.lean">proofs/Proofs.lean</a>. Only the theorem statements are shown; their existing proof bodies are omitted from the article.</p><h4>Arithmetic matches the specification</h4><pre>theorem compute_spec (op : Op) (a b : U64) :<br>    compute op a b ⦃ r =&gt; match r with<br>      | .Ok v =&gt; eval op a.val b.val = .ok v.val<br>      | .Err e =&gt; eval op a.val b.val = .error e ⦄</pre><p>This means that for every operation and every pair of 64-bit inputs, compute completes without an extracted runtime failure, and its result agrees with eval.</p><p>If Rust returns a value v, the specification returns the same value. If Rust returns an application error e, the specification returns that same error. The .val fields expose machine integers as natural numbers.</p><p>The notation ⦃ r =&gt; ... ⦄ expresses successful execution of the extracted computation together with a property of its result. A deliberate Error::Overflow is a valid application result; it is different from the implementation unexpectedly overflowing or panicking.</p><h4>Reading after an update returns its result</h4><pre>theorem get_after (a : Principal) (s s&#39; : Snapshot) (c : Command) (w : Option Memory) (v : U64)<br>    (hroom : s.memories.length &lt; Usize.max)<br>    (ht : transition a s c = ok (.Ok (w, .Value v))) (hs : apply s w = ok s&#39;) :<br>    transition a s&#39; .Get = ok (.Ok (none, .Value v))</pre><p>The parameters are an actor a, an initial snapshot s, a resulting snapshot s&#39;, and any command c. Its hypotheses say:</p><ul><li>hroom: the memory vector has room below the modeled usize bound.</li><li>ht: the command succeeds, producing write w and reply value v.</li><li>hs: applying w succeeds and produces s&#39;.</li></ul><p>The conclusion says that Get in s&#39;, by the same actor, returns v and produces no write.</p><p>This covers Alice setting 6 or multiplying it by 7, but it is not restricted to Alice or those numbers. It quantifies over all values satisfying the hypotheses. It describes the state immediately after this modeled update, with no intervening command.</p><p>There are two success layers in ok (.Ok ...): the outer ok means the extracted computation completes successfully; the inner .Ok is the Rust application&#39;s Result::Ok.</p><h4>Other users’ memory stays unchanged</h4><pre>theorem others_unchanged (a : Principal) (s s&#39; : Snapshot) (c : Command) (w : Option Memory) (reply : Reply)<br>    (hroom : s.memories.length &lt; Usize.max)<br>    (ht : transition a s c = ok (.Ok (w, reply))) (hs : apply s w = ok s&#39;)<br>    (u : Nat) (hu : u ≠ a.user.val) :<br>    memOf s&#39;.memories.val u = memOf s.memories.val u</pre><p>The setup is similar, but now we choose any user u different from the caller. memOf is the specification&#39;s lookup function: it returns that user&#39;s stored value, or zero if the row is absent.</p><p>The conclusion is an equality between the value before and after applying the command. Whatever successful command Alice executes, Bob’s memory stays the same. This is a property of users within the supplied tenant snapshot; the framework’s separate storage machinery handles tenant separation.</p><p>The file also proves writes_own_memory, which establishes that any returned write names the caller, and transition_total, which establishes that the extracted transition completes for every input. The latter still permits intentional application errors such as division by zero.</p><h3>What happens if we introduce a bug?</h3><p>Lean checks the proof bodies supplied in the repository against the extracted implementation. To see why that connection matters, consider a one-character change to the Rust code.</p><p>To see the connection to Rust, change the subtraction condition in compute:</p><pre>// Incorrect: equal operands should produce zero.<br>if a &lt;= b {<br>    Err(Error::Underflow)<br>} else {<br>    Ok(a - b)<br>}</pre><p>The original guard was a &lt; b. With &lt;=, the implementation rejects 5 - 5, while eval requires zero.</p><p>After the changed Rust is translated into Lean, the existing arithmetic proof fails in the subtraction case. At equal operands, the implementation returns Underflow, but compute_spec requires it to agree with eval, which returns zero. The repository&#39;s tutorial demonstrates this failure.</p><p>The proof is tied to the extracted code. If we changed Rust but kept checking an old Lean translation, we would still be checking the old implementation. The repository’s CI regenerates the translation and checks that it matches the committed Lean code.</p><h3>What connects these proofs to the running server?</h3><p>get_after and others_unchanged are about the extracted kernel and its modeled state update. A running server also needs to store and reload that state correctly.</p><p>The calculator’s <a href="https://github.com/h5i-dev/i5h/blob/main/examples/tutorials/calculator/proofs/Storage.lean">Storage.lean</a> connects the generated row operations, writes, and decoding to a shared storage theorem. i5h also contains SQL compilation proofs against a model of PostgreSQL statements.</p><p>PostgreSQL’s actual statement semantics and serializable execution remain assumptions, as does the engine’s implementation of its transaction protocol. The HTTP and authentication layers must supply the intended command and caller. The translation tools and proof checker are also trusted. These boundaries are documented in <a href="https://github.com/h5i-dev/i5h/blob/main/docs/TRUST.md">docs/TRUST.md</a>.</p><p>For this calculator, we have concrete statements to inspect: arithmetic agrees with eval, successful updates can be read back in the resulting state, and other users&#39; values remain unchanged. Each statement refers to the extracted implementation and spells out the conditions under which it holds.</p><h3>Reference</h3><ul><li><a href="https://github.com/h5i-dev/i5h">https://github.com/h5i-dev/i5h</a></li><li><a href="https://github.com/AeneasVerif/aeneas">https://github.com/AeneasVerif/aeneas</a></li><li><a href="https://docs.rs/axum/latest/axum/">https://docs.rs/axum/latest/axum/</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=d80f2e4f0ed1" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How JWT Authentication Actually Works]]></title>
            <link>https://medium.com/@Koukyosyumei/how-jwt-authentication-actually-works-b4246ed637eb?source=rss-43fb167fbb77------2</link>
            <guid isPermaLink="false">https://medium.com/p/b4246ed637eb</guid>
            <category><![CDATA[infosec]]></category>
            <category><![CDATA[authentication]]></category>
            <category><![CDATA[security]]></category>
            <category><![CDATA[web-development]]></category>
            <category><![CDATA[jwt-token]]></category>
            <dc:creator><![CDATA[Hideaki Takahashi]]></dc:creator>
            <pubDate>Sun, 27 Sep 2026 15:36:18 GMT</pubDate>
            <atom:updated>2026-09-29T12:22:42.913Z</atom:updated>
            <content:encoded><![CDATA[<p><em>A tutorial guide to what a token contains, how its signature is checked, and where the trust boundary lies.</em></p><p>When you log in to an application, how does it recognize you on the next request? One common answer is a JSON Web Token, or JWT. To put it simply, it’s a token issued by a server, and the client presents it later. The service that receives it from the client checks whether it should trust that token or not.</p><h3>What is JWT?</h3><p>In a conventional server-side session, an application stores session state and gives the browser a session identifier, often in a cookie. On a later request, the application looks up the identifier.</p><p>JWT-based design instead allows the client to present a singed token, often as Authorization: Bearer &lt;token&gt; or through a cookie. Then, the service verifies the token and checks its claims before deciding what the request may do.</p><p>One of the advantages of tihs JWT-based authentication is that asigned JWT can reduce the need for a central session lookup on every request. Ofcourse, it does <strong>not</strong> mean the application has no other state. Account status, permissions, revocation, and business rules may still require a database or another service.</p><p>Also, JWT is a <em>format</em>, not a login protocol. A JWT may serve as an ID token or an access token, depending on the system. Those tokens have different intended recipients and must not be treated as interchangeable.</p><h3>What is inside a signed JWT?</h3><p>The common compact form has three Base64url-encoded segments separated by dots:</p><pre>base64url(header).base64url(payload).base64url(signature)</pre><p>The header describes how the token is protected. For example:</p><pre>{&quot;alg&quot;:&quot;HS256&quot;,&quot;typ&quot;:&quot;JWT&quot;}</pre><p>The payload contains claims (the bellow is a simplified example with illustrative values):</p><pre>{<br>  &quot;iss&quot;: &quot;https://login.example.test&quot;,<br>  &quot;sub&quot;: &quot;user-123&quot;,<br>  &quot;aud&quot;: &quot;https://api.example.test&quot;,<br>  &quot;exp&quot;: 1893456000<br>}</pre><p>iss identifies the issuer; sub identifies the subject; aud names the intended recipient; and exp is an expiration time expressed as a Unix timestamp. Systems may add claims such as scopes or roles, but a claim is only useful if the receiving service interprets and enforces it correctly.</p><p>The signature protects the encoded header and payload against undetected changes. For an HS256 token, the issuer computes an HMAC over the first two segments using a shared secret, then Base64url-encodes the result.</p><p>Note that Base64url is encoding, and not encryption. Anyone holding an ordinary signed JWT can decode its header and payload.</p><h3>Verification process?</h3><p>Suppose someone changes sub from user-123 to admin-1. The first two encoded segments change, but the old signature remains tied to the original bytes. Thus, averifier with the correct key detects the mismatch.</p><p>The receiving service should also check that the token is unexpired, comes from the expected issuer, is meant for this service, and is of the expected token type. It should enforce the permissions needed for the specific operation. The application must choose which signing algorithms and keys it accepts; it must not let an attacker-controlled header expand that policy.</p><p>In short, the signature says that the protected bytes were issued by a party with the signing key. It does not by itself say that this token is suitable for this API request.</p><h4>Flow 1: the application issues and checks its own token</h4><p>Imagine an application that uses HS256, and it keeps a strong shared secret on the server.</p><ol><li>The user submits login credentials to the application.</li><li>The application authenticates the user and creates a short-lived JWT with appropriate claims.</li><li>It computes the HMAC with its secret and returns the token to the client.</li><li>On a later request, the client presents the token.</li><li>The application verifies the HMAC and checks the claims and the requested operation before responding.</li></ol><p>The client never needs the signing secret. Every service that verifies an HS256 token, however, also has the secret needed to create one. Note that it is important to securely design and implement secret distribution when multiple services are involved.</p><h4>Flow 2: an identity provider issues the token</h4><p>Now separate the issuer from the application. An identity provider signs a token with its <strong>private key</strong>; the application verifies it with the corresponding <strong>public key</strong>. The application can check the token without receiving the issuer’s private key.</p><ol><li>The user authenticates with the identity provider through the appropriate login flow.</li><li>The provider issues a signed token intended for a particular client or resource.</li><li>The client presents the appropriate token to the intended recipient.</li><li>The recipient obtains the provider’s trusted public keys, commonly through a published key set that it can cache.</li><li>The recipient verifies the signature and the relevant claims before granting access.</li></ol><p>The distinction in step 3 matters, where in OpenID Connect, an <strong>ID token</strong> tells the client application about the user’s authentication and is addressed to that client. An <strong>access token</strong> is what a client typically presents to a resource API. Being signed by a familiar provider does not make an arbitrary ID token a valid access token for your API. Likewise, social login can establish identity, but the application may still need to create a local account or session and apply its own authorization rules.</p><h3>A small security checklist</h3><ul><li>Verify the signature using a configured algorithm and trusted key.</li><li>Check iss, aud, exp, and any other claims required by your protocol.</li><li>Keep tokens short-lived where practical and plan how logout or revocation works.</li><li>Treat roles and scopes as authorization inputs, then check the requested resource and action.</li><li>Do not put secrets in a readable payload or expose bearer tokens in logs and shared screenshots.</li></ul><h3>Exploring JWT-protected requests with h5i</h3><p><a href="https://github.com/h5i-dev/h5i">h5i</a> is a red-teaming workspace that lets an agent browse the app and inspect the HTTP requests it produces. You can easily capture an authenticated request, inspect its Authorization header or cookie, replay it with a controlled change, and compare the response. For example, you can check whether an expired JWT token is rejected or whether an endpoint enforces the permissions represented by a valid token.</p><h3><strong>Further reading</strong></h3><ul><li><a href="https://www.rfc-editor.org/rfc/rfc7519">RFC 7519: JSON Web Token</a></li><li><a href="https://www.rfc-editor.org/rfc/rfc8725">RFC 8725: JWT Best Current Practices</a></li><li><a href="https://openid.net/specs/openid-connect-core-1_0.html">OpenID Connect Core</a></li><li><a href="https://h5i.dev/guides/jwt-attacks/">JWT Attacks</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b4246ed637eb" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a Browser Without V8: What Broke, and What Worked]]></title>
            <link>https://medium.com/@Koukyosyumei/building-a-browser-without-v8-what-broke-and-what-worked-bb3bd78235d5?source=rss-43fb167fbb77------2</link>
            <guid isPermaLink="false">https://medium.com/p/bb3bd78235d5</guid>
            <category><![CDATA[javascript]]></category>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[rust]]></category>
            <category><![CDATA[web-development]]></category>
            <category><![CDATA[cybersecurity]]></category>
            <dc:creator><![CDATA[Hideaki Takahashi]]></dc:creator>
            <pubDate>Mon, 21 Sep 2026 16:20:21 GMT</pubDate>
            <atom:updated>2026-09-21T16:20:21.324Z</atom:updated>
            <content:encoded><![CDATA[<p><em>While V8 has dominated JavaScript execution for decades, we tried building a practical browser around Boa, a JavaScript engine written in Rust.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_QBWJac-YPoflbGNhqx0PQ.png" /></figure><p>V8, Google’s JavaScript engine, has become the dominant choice for practical browsers and many embedded runtimes. However, it has also been one of the main source of browser vulnerabilities. Typically, many involve memory-safety problems, such as <a href="https://www.sentinelone.com/vulnerability-database/cve-2026-78910/">CVE-2026–78910</a>, a buffer overflow that could be triggered by a malicious HTML page.</p><p>Many people then are trying to implement a JavaScript engine in memory safe language such as Rust. One example is <a href="https://boajs.dev/">Boa</a>, a JavaScript engine written in Rust. While Rust does not completely eliminate memory bugs, especially when unsafe code is involved, it still removes large classes of them by default.</p><p>For example, Cloudflare recently released <a href="https://blog.cloudflare.com/kitesurf/">Kitesurf</a>, a new browser built largely with Rust components. Yet its main page scripts still run in V8 isolates. Boa is used only for the occasional eval() that Cloudflare Workers cannot execute natively.</p><p>This question has been particularly interesting to me because I have been developing <a href="https://github.com/h5i-dev/h5i">h5i</a>, a CLI-based red-teaming browser that treats both page automation and HTTP traffic capture as first-class capabilities.</p><p>Security teams routinely open unknown or deliberately malicious web pages. For that use case, the safety of the browser itself matters a lot. I therefore decided to use Boa as h5i’s primary JavaScript engine rather than embedding V8.</p><p>So, did it work? Yes, but only after sharply defining what “a practical browser” meant.げｎ</p><h3>What worked</h3><p>Boa is capable enough to run JavaScript used by many ordinary pages. In h5i, scripts can modify the DOM, register event handlers, use promises and timers, call fetch(), load ES modules, and communicate through WebSockets and EventSource. Forms, redirects, cookies, and script-triggered navigation also work.</p><p>This was enough to let h5i interact with pages whose contents are created or changed by JavaScript, maintain a session, click elements, submit forms, and inspect the resulting page and network traffic.</p><p>Boa also gives us a big resource efficiency. In our simple benchmark, h5i loaded a 22 KB documentation page in 59 ms with 65.6 MB of peak memory. Chromium’s headless_shell took 176 ms and 461.5 MB on the same test.</p><p>Removing V8 also means removing its JIT compiler from the attack surface. That is relevant for a browser designed to process hostile pages. It does not prove that h5i is secure, but it avoids one of the most complex and historically vulnerable components found in mainstream browsers.</p><p>Web compatibility was better than I initially expected. h5i currently passes 88,199 of 116,471 subtests, or 75.7%, in its selected core Web Platform Tests (WPT) suite.</p><h3>What broke</h3><p>However, we also encounter various problems.</p><p>For example, Boa does not have V8’s optimizing JIT, and can be slow in a large script-heavy page. In our script-built test page, h5i took 248 ms while Chromium headless_shell took 176 ms.</p><p>The second problem was compatibility. Running JavaScript is not the same as running the modern Web. Applications assume the presence of an enormous collection of browser APIs surrounding the engine. h5i supports the subset needed by the pages we currently target, but it has not yet cleared a production React application.</p><p>As a result, h5i works best for content-heavy sites, common web interactions, scraping, and HTTP-oriented security testing. However, highly interactive applications may still require Chromium.</p><h3>Rust did not automatically make the browser secure</h3><p>Using Rust and removing V8 reduced memory corruption in the JavaScript engine and JIT, although it did not solve browser security as a whole.</p><p>For instance, after implementing fetch(), we found a policy bug involving loopback access. A hostile page could request a local development service, read the response through JavaScript, and send it to another allowed destination. Although the code was memory-safe, the security model was wrong. We fixed it by checking both the destination and the document that initiated the request.</p><h3>Was it worth it?</h3><p>For h5i, yes.</p><p>The goal of h5i was not to replace Chrome for everyday browsing, but to build a lightweight browser for automated and security-sensitive red-teaming tasks, where lower memory use, a smaller attack surface, and control over the full browser stack can matter more than perfect compatibility.</p><p>Boa was practical for that scope, because it runs enough JavaScript to make h5i a real interactive browser, while keeping the project in Rust and avoiding a V8 dependency.</p><p>The answer would be different for a general-purpose browser. If the requirement is to run every major web application exactly as Chrome does, using V8 or another mature engine such as SpiderMonkey or JavaScriptCore remains the realistic choice.</p><h3>Reference</h3><ul><li><a href="https://h5i.dev/">https://h5i.dev/</a></li><li><a href="https://github.com/h5i-dev/h5i">https://github.com/h5i-dev/h5i</a></li><li><a href="https://medium.com/@Koukyosyumei/think-like-an-attacker-ci-cd-security-in-the-ai-era-39f717f5e9cd">Think Like an Attacker: CI/CD Security in the AI Era</a></li><li><a href="https://medium.com/@Koukyosyumei/i-built-a-headless-browser-for-ai-agents-entirely-in-rust-no-chromium-no-v8-3750908b8145">I Built h5i, a Headless Browser for AI Agents Entirely in Rust. No Chromium, No V8</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=bb3bd78235d5" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Think Like an Attacker: CI/CD Security in the AI Era]]></title>
            <link>https://medium.com/@Koukyosyumei/think-like-an-attacker-ci-cd-security-in-the-ai-era-39f717f5e9cd?source=rss-43fb167fbb77------2</link>
            <guid isPermaLink="false">https://medium.com/p/39f717f5e9cd</guid>
            <category><![CDATA[github-actions]]></category>
            <category><![CDATA[continuous-integration]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[hacking]]></category>
            <category><![CDATA[information-security]]></category>
            <dc:creator><![CDATA[Hideaki Takahashi]]></dc:creator>
            <pubDate>Fri, 18 Sep 2026 14:06:14 GMT</pubDate>
            <atom:updated>2026-09-19T15:08:46.879Z</atom:updated>
            <content:encoded><![CDATA[<p><em>How to turn a working exploit into a repeatable GitHub Actions test with h5i</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*V4iq_warwXy2ExuA-_QuzQ.jpeg" /></figure><p>AI has made it much easier to find potential vulnerabilities, inspect application code, mutate HTTP requests, and assemble proof-of-concept attacks.</p><p>However, finding a vulnerability is only half the job. A bug may be fixed today and quietly reintroduced during a refactor six months later (it often happenes to me when I use Claude or Codex). A new endpoint may repeat the same authorization mistake.</p><p>Thus, it is significantly important to implement the attack as a regression test that runs in CI/CD, rather than just fixing a bug.</p><p>In this article, I will show how to encode a confirmed attack flow with <a href="https://github.com/h5i-dev/h5i">h5i</a> and replay it in GitHub Actions.</p><h3>Example Scenario</h3><p>Suppose an application exposes this endpoint:</p><pre>GET /api/documents/{documentId}</pre><p>During testing, we discover an IDOR vulnerability: after logging in as Alice, changing the document ID in the URL may return a document owned by Bob. Here, the security property that we want to satisfy is “<em>Alice must not be able to read Bob’s document.”</em></p><p>Hence, we want CI to perform the following steps on every pull request:</p><ol><li>Log in as Alice.</li><li>Request a document that belongs to Bob.</li><li>Verify that the application denies access.</li><li>Verify that Bob’s data does not appear in the response body.</li></ol><p>You could express this in an application-specific integration test. But preserving the actual HTTP attack flow gives us a useful black-box property: we test what an external attacker can observe, rather than only the internal function that was patched.</p><h3>What h5i does in CI</h3><p><a href="https://github.com/h5i-dev/h5i">h5i</a> is a lightweight browser and HTTP testing tool for red-team. While h5i is quite useful for interactive red-teaming, we can also easily integrate it with CI/CD pipeline like GitHub Actions.</p><pre>repository-owned attack flow<br>            ↓<br>     structured evidence<br>            ↓<br>application-owned oracle</pre><p>h5i replays the requests and records the responses. A repository-owned program, called an oracle, decides whether the evidence satisfies the application’s security property.</p><h4>Repository layout</h4><p>For this example, we add four files:</p><pre>.<br>├── .github/<br>│   └── workflows/<br>│       └── security.yml<br>├── .h5i-tests/<br>│   ├── oracles/<br>│   │   └── document-isolation.sh<br>│   └── tests/<br>│       └── document-isolation.yaml<br>└── openapi.yaml</pre><ul><li>document-isolation.yaml describes the HTTP requests and their order.</li><li>document-isolation.sh decides whether the observed responses are secure.</li><li>security.yml starts the application and runs the test in GitHub Actions.</li><li>openapi.yaml is optional and provides a denominator for API-operation coverage.</li></ul><h4>1. Describe the attack flow</h4><p>Create .h5i-tests/tests/document-isolation.yaml:</p><pre>version: h5i.test/v1<br>id: document-isolation<br>name: Alice cannot read Bob&#39;s document<br><br>actors:<br>  alice: {}<br>requests:<br>  login:<br>    method: POST<br>    path: /api/session<br>    headers:<br>      content-type: application/json<br>    json:<br>      username: alice<br>      password: &quot;${env.H5I_ALICE_PASSWORD}&quot;<br>  other-users-document:<br>    method: GET<br>    path: &quot;/api/documents/${env.H5I_OTHER_DOCUMENT_ID}&quot;<br>    headers:<br>      accept: application/json<br>flow:<br>  - send: login<br>    as: alice<br>    save: login_result<br>  - send: other-users-document<br>    as: alice<br>    save: attack<br>    covers:<br>      operation: getDocument<br>      mutation: identifier-substitution<br>oracle:<br>  command: [../oracles/document-isolation.sh]<br>  inputs: [login_result, attack]<br>  timeout_seconds: 10</pre><p>An actor owns an isolated cookie jar, so the second request uses the session established by the first request.</p><p>Values such as the password and Bob’s document ID come from environment variables through ${env.NAME}. The CI job should use seeded, non-production fixtures. Production credentials and production customer data do not belong in this test.</p><p>The covers block records that this flow exercises the OpenAPI operation getDocument using an identifier-substitution attack. We will use that metadata later for coverage reporting.</p><h3>2. Define what “the attack failed” means</h3><p>Now create .h5i-tests/oracles/document-isolation.sh:</p><pre>#!/bin/sh<br>set -eu<br><br>login_status=&quot;$(jq -r &#39;.responses.login_result.status&#39; &quot;$H5I_TEST_RESULT&quot;)&quot;<br>attack_status=&quot;$(jq -r &#39;.responses.attack.status&#39; &quot;$H5I_TEST_RESULT&quot;)&quot;<br>attack_body=&quot;$(jq -r &#39;.responses.attack.body&#39; &quot;$H5I_TEST_RESULT&quot;)&quot;<br>forbidden_marker=&quot;${H5I_FORBIDDEN_MARKER:?set H5I_FORBIDDEN_MARKER}&quot;<br># If login failed, the security property was not actually tested.<br>case &quot;$login_status&quot; in<br>  200|204) ;;<br>  *) exit 2 ;;<br>esac<br># Alice&#39;s request for Bob&#39;s document must be denied.<br>case &quot;$attack_status&quot; in<br>  403|404) ;;<br>  *) exit 1 ;;<br>esac<br># Bob&#39;s data must not appear in the response body either.<br>if grep -Fq &quot;$forbidden_marker&quot; &quot;$H5I_TEST_ARTIFACTS/$attack_body&quot;; then<br>  exit 1<br>fi</pre><p>h5i provides the oracle with three relevant environment variables:</p><ul><li>H5I_TEST_RESULT points to a JSON evidence bundle containing response metadata.</li><li>H5I_TEST_ARTIFACTS points to the owner-only directory containing response bodies and related evidence.</li><li>H5I_TEST_INPUTS contains the saved response names declared in oracle.inputs.</li></ul><p>In h5i, the oracle contract is intentionally small:</p><ul><li>Exit 0: the security property held.</li><li>Exit 1: the security property was violated.</li><li>Any other exit code or a timeout: the test could not reach a conclusion.</li></ul><h4>3. Replay it in GitHub Actions</h4><p>Add .github/workflows/security.yml:</p><pre>name: Security regression tests<br><br>on:<br>  pull_request:<br>  push:<br>    branches: [main]<br>permissions:<br>  contents: read<br>jobs:<br>  security-regression:<br>    runs-on: ubuntu-latest<br>    timeout-minutes: 15<br>    steps:<br>      - uses: actions/checkout@v4<br>      - name: Start application<br>        run: docker compose up -d --wait<br>      - name: Replay security regression tests<br>        uses: h5i-dev/h5i@v1<br>        env:<br>          # Seeded, non-production CI fixtures<br>          H5I_ALICE_PASSWORD: alice-ci-password<br>          H5I_OTHER_DOCUMENT_ID: &quot;2&quot;<br>          H5I_FORBIDDEN_MARKER: bob@example.test<br>        with:<br>          target: http://localhost:3000<br>          tests: .h5i-tests/tests<br>          openapi: openapi.yaml<br>          output: h5i-test-results<br>      - name: Upload h5i evidence<br>        if: always()<br>        uses: actions/upload-artifact@v4<br>        with:<br>          name: h5i-test-results<br>          path: h5i-test-results/<br>          if-no-files-found: warn</pre><p>This example assumes that docker compose up -d --wait starts the application at http://localhost:3000. Replace that command with however your project starts its integration-test environment.</p><p>The h5i action writes:</p><ul><li>result.json for structured processing,</li><li>junit.xml for CI test reporting, and</li><li>per-response artifacts for investigation when a test fails.</li></ul><p>Response evidence may contain sensitive application data. Treat the uploaded directory accordingly: restrict access, choose an appropriate retention period, and avoid using production data.</p><p>For stricter supply-chain controls, pin h5i-dev/h5i@v1 and other third-party actions to reviewed commit SHAs rather than moving tags.</p><h4>4. Measure attack coverage</h4><p>As the suite grows, a simple test count becomes less informative. Ten passing attacks might cover ten different API operations, or the same operation ten times.</p><p>h5i can compare each test’s covers.operation field with the operationId values in an OpenAPI document. It then reports the proportion of API operations for which an attack flow reached a conclusive oracle result.</p><p>A minimal openapi.yaml could look like this:</p><pre>openapi: 3.0.0<br>info:<br>  title: Example document API<br>  version: 1.0.0<br>paths:<br>  /api/documents/{documentId}:<br>    get:<br>      operationId: getDocument<br>      responses:<br>        &quot;200&quot;:<br>          description: Document<br>        &quot;403&quot;:<br>          description: Forbidden<br>  /api/profile:<br>    get:<br>      operationId: getProfile<br>      responses:<br>        &quot;200&quot;:<br>          description: Current user profile</pre><p>This metric is not code coverage, and it is certainly not the probability that the application is vulnerability-free. It answers <em>how many declared API operations do we have at least one attack flow whose evidence was checked by an oracle.</em></p><h3>Summary</h3><p>AI can accelerate the exploratory side of security testing: reading code, identifying suspicious flows, enumerating endpoints, mutating requests, and proposing exploit chains.</p><p>But asking an agent to rediscover every vulnerability from scratch on every pull request is neither deterministic nor efficient. Even a capable model may take a different path, overlook a previously known bug, or stop before reproducing the exact condition that matters.</p><p>A better division of labor is:</p><ul><li>Explore: A human or AI discovers an unknown vulnerability.</li><li>Freeze: The confirmed request sequence and expected defense become reviewable repository files.</li><li>Regress: CI replays the attack deterministically on every relevant change.</li></ul><p>As vulnerability discovery gets cheaper, retaining what we learn becomes more important. The final step after fixing a security bug should be to make the exploit fail forever.</p><h3>Links</h3><ul><li><a href="https://github.com/h5i-dev/h5i">h5i on GitHub</a></li><li><a href="https://github.com/h5i-dev/h5i/tree/main/examples/security-regression-ci">Complete security regression CI example</a></li><li><a href="https://github.com/h5i-dev/h5i/blob/main/docs/design/design-test.md">Design of portable h5i security tests</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=39f717f5e9cd" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Top 20 AI Agent Pen-Testing Tools for 2026: The Ultimate Guide]]></title>
            <link>https://medium.com/@Koukyosyumei/top-20-ai-agent-pen-testing-tools-for-2026-the-ultimate-guide-40eb18d59138?source=rss-43fb167fbb77------2</link>
            <guid isPermaLink="false">https://medium.com/p/40eb18d59138</guid>
            <category><![CDATA[security]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[infosec]]></category>
            <category><![CDATA[hacking]]></category>
            <category><![CDATA[pentesting]]></category>
            <dc:creator><![CDATA[Hideaki Takahashi]]></dc:creator>
            <pubDate>Thu, 10 Sep 2026 22:26:04 GMT</pubDate>
            <atom:updated>2026-09-10T22:26:04.262Z</atom:updated>
            <content:encoded><![CDATA[<p>Traditionally, we have spent a lot of effort to automate a penetration test, mostly by automating individual tools. For example, Nuclei ran templates, ZAP crawled and scanned, Metasploit executed modules, and Burp Suite organized HTTP traffic and gave a human tester an excellent place to investigate it.</p><p>However, in 2026, AI agent can now sit above them, choose the next action, interpret and summarize the result, revise its hypothesis, and continue, just like professional human ethical hackers.</p><p>As a consequence, a lot of agent-native solutions and tools have poped up this year. Some try to automate and connect the existing softwares like Burp or ZAP with general-purpose agents, while others provides the agent-native solutions that are mostly headless CLI tools.</p><p>This article maps that landscape as it stands in September 2026. Note that this blog is not a ranking or recommendation, solely aiming to explain what each category is for, where representative tools fit, and what still remains unsolved.</p><h3>Category — 1: Automated pentesting</h3><p>There are many services that try to provide the end-to-end process of pentesting as automated services. It typically includes defining a target and scope, lettting the system test it, and receiving validated findings, attack paths, or counter-examples.</p><h4>1. XBOW</h4><p><a href="https://xbow.com/">XBOW</a> is a AI-native offsec platform that emulates hacker behaviors to find, chain, and validate critical vulnerabilities in software. XBOW has startted a lot of attentino after it achieved the top of HackerOne’s US leaderboard in 20205. It runs without a human supervision, and returns vulnerabilities with concrete evidences.</p><h4>2. Horizon3.ai NodeZero</h4><p><a href="https://horizon3.ai/">NodeZero</a> focuses on autonomous pentesting across a broader situations, such as internal and external infrastructure, Active Directory, cloud, Kubernetes, and web applications. It tries to identify realistic attack paths that show how an attacker could exploit target environment and reach valuable assets, rather than just returring isolated lists of potential bugs.</p><h4>3. Pentera</h4><p><a href="https://pentera.io/">Pentera</a>, one of the most popular automated seucirty tools, has been also shifting to AI-driven approach. Like NodeZero, it covers internal/external, cloud, identity, and application exposure.</p><h4>4. Hadrian Nova</h4><p><a href="https://hadrian.io/">Hadrian</a> combines external attack-surface discovery with continuous validation. It’s agentic pentesting feature lets AI agents analyze exposed assets, exuecute tests, and explore potential attack path.</p><h4>5. Escape</h4><p><a href="https://escape.tech/">Escape</a> tests APIs, including GraphQL, and discovers endpoints without needing a schema uploaded first. It runs inside the software-delivery lifecycle</p><h4>6. Aikido</h4><p><a href="https://www.aikido.dev/">Aikido Security</a> also incorporates autonomous pentesting into a broader developer security platform. It positiones itself as all-in-one application security platform, and provides AI-driven pentesting, secrets and malware detection, and automatic bug fix.</p><h4>7. Terra</h4><p><a href="https://www.terra.security/">Terra Security</a> takes an explicitly human-supervised position within the commercial segment. Its agents run continuous, business-context-aware tests across web apps, APIs, mobile, internal applications, networks, and cloud.</p><h3>2. Open-source autonomous agents</h3><p>Open-source community on agentic security has been also evolving significantly.</p><h4>8. Strix</h4><p><a href="https://github.com/usestrix/strix">Strix</a> is an open-source application-security agent that fully automates the pentesting and bug fix worlflows. It dynamically runs code, tests applications, and attempts to validate findings with working proofs of concept. It can be also integrated with multiple agents, CLI and CI/CD workflows.</p><h4>9. Shannon</h4><p><a href="https://github.com/KeygraphHQ/shannon">Shannon</a> is also an autonomous pentester for web applications and APIs. It combines source-code analysis with live exploitation, using the code to identify candidate attack paths and the running target to prove whether they are real.</p><h4>10. PentAGI</h4><p><a href="https://github.com/vxcontrol/pentagi">PentAGI</a> is a self-hosted multi-agent system for complex penetration-testing tasks. It gives agents access to security tooling and web search, separates roles, stores operational context, and provides an interface for supervising runs.</p><h4>11. PentestGPT</h4><p><a href="https://github.com/GreyDGL/PentestGPT">PentestGPT</a> first begans as an LLM-assisted penetration-testing workflow and has evolved toward a more agentic framework capable of planning and executing security and CTF tasks.</p><h4>12. Nebula</h4><p><a href="https://github.com/berylliumsec/nebula">Nebula</a> deliberately takes a more human-led position. It combines a terminal, browser, editor, files, notes, findings, reports, and AI assistance in one engagement workspace. The operator defines scope and decides what runs.</p><h4>13. CAI</h4><p><a href="https://github.com/aliasrobotics/CAI">CAI</a> is an open-source framework for building your own security agents rather than a finished product. It provides agents, tools, handoffs, multi-agent patterns, and optional human-in-the-loop supervision, and it connects to the tooling a team already runs, such as Nmap or Burp.</p><blockquote>Note that CAI has been archived, though it’s source code is still available.</blockquote><h3>3. General-purpose agents connected to security tools</h3><p>A second open-source movement starts with Claude Code, Codex, Cursor, or another general-purpose agent and gives it security capabilities. Model Context Protocol, or MCP, has become a common integration layer.</p><h4>14. HexStrike AI</h4><p><a href="https://github.com/0x4m4/hexstrike-ai">HexStrike AI</a> is an MCP server that exposes a large collection of offensive-security tools to AI agents. Instead of building a proprietary reasoning system, it lets the user bring an agent and equips that agent to invoke scanners, recon tools, web-testing utilities, and exploitation frameworks.</p><h4>15. Burp Suite’s MCP server and Burp AT</h4><p>PortSwigger provides an official <a href="https://portswigger.net/bappstore/9952290f04ed4f628e624d0aa9dccebc">Burp Suite MCP Server extension</a>, allowing AI clients to analyze traffic and interact with Burp programmatically. PortSwigger has also introduced <a href="https://portswigger.net/blog/introducing-burp-at">Burp AT</a>, an agentic capability inside Burp Suite Professional. It takes a human-led approach: the agent receives Burp’s project context and specialist tools, while the tester retains control of scope, tasks, and conclusions.</p><h4>16. Caido</h4><p><a href="https://www.caido.io/blog/2026-07-14-agentic-pentesting-with-mcp/">Caido</a> is following a similar path with an AI skill and MCP-backed access to proxy traffic and testing operations. A general-purpose agent can inspect requests, send modified versions to replay, examine automation results, and work within the proxy context.</p><h4>17. AutoPentest</h4><p><a href="https://github.com/bhavsec/autopentest-ai">AutoPentest</a> packages web-testing workflows behind MCP, drawing on the OWASP Web Security Testing Guide and PortSwigger’s educational material. Many smaller projects expose Nmap, Nuclei, SQLMap, Metasploit, or custom recon pipelines in the same way.</p><h3>4. Agent-native execution primitives</h3><p>There are also many tools designed to give an agent a better way to observe or act.</p><h4>18. h5i</h4><p><a href="https://github.com/h5i-dev/h5i">h5i</a> is an open-source, security-oriented headless browser for AI-driven red-teaming. It combines automated browsing with direct access to its own HTTP traffic. An agent can browse an application, capture requests, edit and replay them, compare responses, and run bounded recon through one command-line interface. It also provides several tiers of sandboxes to make the workflow safer.</p><h4>19. Nuclei</h4><p><a href="https://github.com/projectdiscovery/nuclei">Nuclei</a> remains one of the most useful deterministic engines beneath an agent. Its YAML templates provide fast, reproducible checks across applications, infrastructure, and cloud services. An agent can use Nuclei for broad coverage, interpret the results, select follow-up tests, or generate a regression template after confirming a new issue.</p><h4>20. OWASP ZAP</h4><p><a href="https://www.zaproxy.org/docs/automate/">OWASP ZAP</a> remains relevant because it provides a mature proxy, scanner, API, daemon mode, and YAML-based Automation Framework. Those are exactly the kinds of stable, inspectable capabilities an agent can orchestrate.</p><h3>5. What is still unsolved</h3><p><strong>Triage came back.</strong> Autonomous validation was supposed to remove the false-positive problem. In practice a platform that produces hundreds of findings still hands the sorting work to a team that was already behind on alerts, and validation quality varies enough that the sorting cannot be skipped.</p><p><strong>Chaining and business logic still favor humans.</strong> In the December 2025 ARTEMIS study on a live 8,000-host network, the best autonomous agent beat 9 of 10 OSCP-certified testers but lost to the top human, 9 findings to 13, and it lost on creative chaining and business logic. That gap is the difference between a scan and a pentest.</p><h3>Reference</h3><ul><li><a href="https://strobes.co/blog/open-source-agentic-pentesting-tools/">https://strobes.co/blog/open-source-agentic-pentesting-tools/</a></li><li><a href="https://www.reddit.com/r/cybersecurity/comments/1usbadf/best_platforms_for_continuous_security_validation/">https://www.reddit.com/r/cybersecurity/comments/1usbadf/best_platforms_for_continuous_security_validation/</a></li></ul><p><em>Use all offensive-security tools only against systems you own or are explicitly authorized to test.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=40eb18d59138" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Do We Still Need Burp Suite for Web Security Testing?]]></title>
            <link>https://medium.com/@Koukyosyumei/do-we-still-need-burp-suite-for-web-security-testing-00f068851d30?source=rss-43fb167fbb77------2</link>
            <guid isPermaLink="false">https://medium.com/p/00f068851d30</guid>
            <category><![CDATA[security]]></category>
            <category><![CDATA[web-development]]></category>
            <category><![CDATA[hacking]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[ctf]]></category>
            <dc:creator><![CDATA[Hideaki Takahashi]]></dc:creator>
            <pubDate>Mon, 07 Sep 2026 16:09:55 GMT</pubDate>
            <atom:updated>2026-09-07T16:09:55.562Z</atom:updated>
            <content:encoded><![CDATA[<p><em>Burp and ZAP were built more than ten years ago, while AI agents can solve many CTF / bug-detection benchmakrs with CLI tools.</em></p><p>Burp was first released in <a href="https://portswigger.net/blog/burp-through-the-ages">2003</a>, and ZAP was built around <a href="https://www.zaproxy.org/blog/2020-09-06-zap-is-ten-years-old/">2010</a>. Caido is much more recent, built in 2022, but still before the coding-agent era. Both were designed for humans, while the web security landscape has shifted dramatically toward AI agents in recent years.</p><p>Today, an AI agent in a terminal can use command-line tools to browse an application, inspect HTTP traffic, write scripts to replay requests, research relevant vulnerabilities, and choose its next test based on the results, without opening any GUI-based systems like Burp.</p><p>Of course, Burp remains useful for automated scanning, interactive investigation, and reviewing an agent’s work. It also recently relreased MCP-based support for AI agents. Whether to use it depends on the tests you need to run and how you want to inspect the results.</p><h3>Agents can combine CLI tools to run the entire worlflow of web security tests</h3><p>An agent can use <a href="https://nmap.org/book/man.html">Nmap</a> for port and service discovery, <a href="https://github.com/phra/rustbuster">rustbuster</a> for web content discovery, <a href="https://curl.se/docs/manpage.html">curl</a> for HTTP requests, <a href="https://github.com/ffuf/ffuf">ffuf</a> for fuzzing, and <a href="https://github.com/projectdiscovery/nuclei">Nuclei</a> for existing vulnerability checks. <a href="https://docs.mitmproxy.org/stable/">mitmdump</a> captures and modifies traffic, <a href="https://playwright.dev/docs/library">Playwright</a> scripts automate full browsers, and <a href="https://github.com/h5i-dev/h5i">h5i</a> combines headless browsing with HTTP inspection, replay, and modification.</p><blockquote>Disclosure: I’ve been an active user of Burp for several years, and also am the maintainer of <a href="https://github.com/h5i-dev/h5i">h5i</a> (600+ GitHub stars), an open-source security-first headless browser with built-in HTTP traffic capturing and modification. I admit that my preference for Burp Suite, along with my optimism regarding human-AI collaboration, might introduce some bias in this article.</blockquote><p>AI agents tend to be very good at connecting these tools with bash scripts, such as repeating a request under different sessions, comparing responses, and saving a working reproduction. I really consider Burp Suite one of the best tools for web security testing, but agents can already complete many of these tasks using CLI tools alone, without Burp or its MCP integration.</p><h3>Agents can solve most challenges in these benchmarks using only CLI tools</h3><p>Thesedays, many researches have shown that AI agents can solve suprisingly massive areas of security tasks with only CLI tools.</p><p><a href="https://arxiv.org/html/2402.06664v1"><em>“LLM Agents can Autonomously Hack Websites</em></a><em>”</em> gaves agents a Playwright headless browser, basic CLI tools like curl, and a Python interpreter. The agents use them to test sandboxed websites and execute multistep attacks, including database schema extraction and SQL injection.</p><p>Their follow-up, <a href="https://arxiv.org/html/2404.08144v2"><em>LLM Agents can Autonomously Exploit One-day Vulnerabilities</em></a>, gave agents browser operations, a terminal, web search results, file editing, and a code interpreter. In one case, the agent navigated a checkout flow, collected the required fields, wrote a Python script to exploit a race condition, and executed it through the terminal. GPT-4 exploited 87% of the 15 vulnerabilities when supplied with CVE descriptions, compared with 7% without them.</p><p>Both studies used terminal commands and programmatic browser tools to execute attacks. They support testing without a human operating a security GUI, although neither was a CLI-only evaluation.</p><p>I also confirmed the agents’ security-related problem solving power with only CLI tools. In my own <a href="https://github.com/h5i-dev/h5i-benchmark">h5i-benchmark</a> experiment, a general-purpose agent like the normal Opus 5.0 model solved <strong>100 of 104 XBOW validation challenges and 54 of 60 Argus validation challenges</strong> through <a href="https://github.com/h5i-dev/h5i">h5i</a>, python script, and other basic CLIs like curl. There are also some teams who have already shown that stronger agents or domain-specific agents can solve more challenges.</p><blockquote>Caution: other teams had already published writeups of XBOW and Argus before our experiment. While the agent in my experiment did not directly access or observe those solutions during the run, there is a possibility that those writeups might have been included in its training data. These results only demonstrate that h5i supported the interactions needed to solve most challenges in the two corpora, and they do not show the agents’U performance on unseen vulnerabilities.</blockquote><h3>Burp still helps testers review agent findings</h3><p>A GUI-based workspace of Burp is quite helpful for humanto inspect traffic and review uncertain findings. This process is still necessary when we want to check whether AI-generated report is valid or not. There are also relatively modern alternatives such as <a href="https://www.caido.io/features/">Caido</a> or <a href="https://www.zaproxy.org/">ZAP</a>.</p><p>These tools can also work with agents. For example, both of Burp and Caido supports MCP integration with AI agents, so that human testers can automate the workflow with agents.</p><p>I think Burp, Caido, or ZKP are still necessary for exploratory testing and manual follow-up, while agents using CLI tools would defenitely make the entire workflow more efficient.</p><h3>Model providers can block authorized testing</h3><p>An agent may be capable of a test that its provider blocks. Anthropic’s <a href="https://support.claude.com/en/articles/14604842-real-time-cyber-safeguards-on-claude-opus-and-sonnet">cyber safeguards documentation</a> describes default blocks on certain high-risk dual-use activities, including activities with legitimate defensive applications. It offers a verification process for eligible users and acknowledges that approved users can still encounter blocks on legitimate work.</p><p>These restrictions aim to prevent abuse, but they can also interrupt an authorized assessment. Permission from the application owner does not guarantee access to the model capabilities needed for a test.</p><p>Save reviewed reproduction scripts, captured traffic, and explicit assertions so the team can verify findings without asking the model to repeat the investigation. A reproduction script can also become a regression test after the vulnerability is fixed.</p><h3>We human still need the skills to verify agent findings</h3><p>I would keep practicing manual testing and bug discovery even if AI agents can handle most workflows. Reviewing theiir output requires understanding HTTP, sessions, access controls, and the application’s intended behavior. Model providers like OpenAI and Anthropic may also downgrade the security-related capabilities of agents (which they already do).</p><p>Agents can make the bug discovery and report writing process dramatically more efficient, but human testers still need to inspect the evidence, reproduce findings, and continue the assessment when the model fails or refuses a task. Burp and other interactive tools remain useful for that work.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=00f068851d30" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>