Perennial's goose framework and program proofs, in Lean 4 on top of
iris-lean. This branch
(lean) is the primary branch. It covers Go programs translated by goose,
with a crash semantics and crash adequacy theorem but no crash program logic.
Guides: docs/PERENNIAL_PROOF_TUTORIAL.md,
docs/PERENNIAL_PROOF_REFERENCE.md,
docs/IRIS_PROOF_MODE.md.
-
One library,
Perennial. Framework directories are UpperCamelCase (Perennial/Golang/Theory/Slice.lean); generated, spec and proof directories follow Go package paths (Perennial/Proof/github_com/tchajed/marshal.lean). -
One spec per Go function, in its own file.
Perennial/Spec/<pkg>/<F>.leanholds the one Hoare triple of Go functionF(T__Mfor a method), andPerennial/Proof/<pkg>/<F>.leanits proof, which takes the specs of its callees as hypotheses and imports only theirSpecfiles;Perennial/Proof/<pkg>/Link.lean(generated byetc/gen-link.py) applies each proof to its callees' results, as<pkg>.wp_<F>, andPerennial/Proof/<pkg>.leanis the module clients import. Seedocs/PERENNIAL_PROOF_REFERENCE.md, "Layout of the program proofs". -
Standard WP, crashes in the semantics and adequacy only. GooseLang is an instance of iris-lean's
Language, and proofs use iris-lean'swp, which has later credits andnumLatersPerStep; there is no crash WP (wpc). The local and global state (state × GlobalState) form the single iris-leanState,CfgState. A crash and restart of the node (Crash,GooseLang/Lang.lean) kills every thread, gives the restarted program an arbitrary heap and a fresh Go runtime (no package initialized), and crashes each extension's local state by the extension's own relation (ExtSemantics.crash); the global state is kept.goose_crash_adequacy(GooseLang/CrashAdequacy.lean) is about executions with crashes (RealCrashNsteps,GooseLang/CrashLang.lean) of a boot program, which runs at the start and after every crash: each generation is proved with the standard WP, under the shared global ghost state and its own local ghost state (GooseLocalGS), from a client-chosen preconditionP Lof the generation's local ghost stateL, which each generation's proof guarantees for the next generation after its crash, from its persistentcrashedtoken (exclusive with thenotCrashedthat its state interpretation owns,GooseLang/CrashToken.lean). There are no crash reasoning principles yet (nocrash_own). End-to-end example: the Go packagegoose/testdata/examples/crashfs, a boot program on a toy filesystem (GooseLang/Ext/Fs, Go sidecrashfs/fsext; not a sound model of a Linux filesystem, seeGooseLang/Ext/Fs/Impl.lean) whosepanicassertions never fire however many times the node crashes (crash_safety,Perennial/Proof/github_com/mit_pdos/perennial/goose/testdata/examples/crashfs/Results.lean). -
Distributed systems. A distributed system (
GooseLang/DistLang.lean) is a list of nodes, each with its own thread pool and local state, sharing the global state; a step is a real thread-pool step of one node.goose_dist_adequacy(GooseLang/DistAdequacy.lean) is about its executions: each node's program is proved with the standard WP, under the shared global ghost state and its own local ghost state (nodeWp), after a global setup that may allocate the invariants the nodes share. There is no distributed program logic. With crashes (DistCrashNsteps), a node crashes at any time and restarts from a crashed local state with its boot program, the one program it has (no separate recovery procedure);goose_dist_crash_adequacycombines this with crash adequacy: each generation of each node is proved with the standard WP from a client-chosen preconditionP i L, which it guarantees for the node's next generation, as ingoose_crash_adequacy. End-to-end example: the Go packagegoose/testdata/examples/netfs, three nodes on a toy network (GooseLang/Ext/Net, Go sidenetfs/netext, whose primitives are stubs: the extension is a specification of a network) and the toy filesystem, where a node's reply after a reboot depends on the durability and order of its earlier writes, and whosepanicassertions never fire however often the nodes crash (netfs_crash_safety,Perennial/Proof/github_com/mit_pdos/perennial/goose/testdata/examples/netfs/Results.lean). -
Bounded layer, time receipts and thread tokens. The trusted semantics
BaseStep(and its iris-lean languagegooseRealEctxiLang,GooseLang/Lang.lean) is the model of Go, but the language instance used by the program logic is a separate bounded layer (GooseLang/BoundedLang.lean): its state adds a fuel⟨steps, threads⟩. Once the step fuel is exhausted, Go instructions stutter instead of stepping; this supports time receipts (Mével, Jourdan, Pottier, ESOP 2019;GooseLang/Receipts.lean):⧗ n/⧖ n, with⧗ N ⊢ Falsefor the boundN = receiptBound GF. Once the thread fuel is exhausted,Forkstutters; a thread's exit returns thread fuel. This supports thread tokens (GooseLang/Threads.lean):threadToks n, one per live thread, withthreadToks T ⊢ Falsefor the boundT = threadBound GF, so that a counter backed by one token per pending goroutine (async.WaitGroup's) is belowT, and that a stock ofT - 1copies of a resource lent to threads for their thread tokens never runs out (Proof/ThreadPool.lean; e.g. anRWMutex's reader tokens,Proof/sync/RWMutexThreads.lean). A token splits intoTlevels (levelTok l), each with its ownT - 1tokens, so that a thread can lend part of its token and keep the rest (sync.tokJoinL, pools at a level); the levels are ghost state only. For this the trusted semantics counts the live threads (state.threads):Fork espawnse ;; ThreadExitand increments it, and the no-opThreadExitdecrements it. The bounds are unspecified parameters, not constants: they are fields of the ghost stateReceiptGS GF/ThreadGS GF(parts ofGooseGlobalGSand of the per-generationGooseLocalGS, hence ofHeapGS), so downstream files, whose sections already assumeHeapGS, need no new argument, and the language instance and itsPureExec/Atomicinstances do not depend on them. A proof that needs a bound to be small takes a premise (wp_clock_incrinProgramLogic/TimeReceiptsTest.leantakesreceiptBound GF ≤ 2 ^ 64;wp_counter_registerinProgramLogic/ThreadTokensTest.leantakesthreadBound GF ≤ 2 ^ 31). The adequacy theorems (goose_adequacy N T,goose_invariance N T, and the grove/disk ones) hold for everyNandT: they allocate the ghost state withreceiptBound GF = NandthreadBound GF = T(hypotheses of the WP premiseHwp, which also receives the main thread's token, and from which the client discharges the proof's premises about the bounds; the main thread's postcondition must exclude panics, see "Panics" below) and are about real executions of fewer thanNsteps along which fewer thanTthreads are live, explicit hypotheses. Seedocs/PERENNIAL_PROOF_REFERENCE.md, "Time receipts" and "Thread tokens". -
Iris substrate. iris-lean provides the BI, proof mode, invariants, ghost maps, later credits and the WP. General-purpose libraries that iris-lean lacks (finite maps and sets, machine words, list lemmas) live in
Perennial/Std.- Finite maps are
Perennial.gmap K V(Perennial/Std/GMap.lean): finite partial functions, extensional so=is equality of bindings, needing onlyDecidableEq K.gset K = gmap K Unit. It is an iris-leanLawfulFiniteMap, so iris-lean'sghost_map/gen_heapapply. - Machine words are
BitVec n(w64 = BitVec 64, ...).uint.Z xis(x.toNat : Int)andsint.Z xisx.toInt. Arithmetic side conditions are discharged withword,omegaandbv_omega. Do not usebv_decide/native_decide: they trust native code (Lean.ofReduceBool); prove bitwise facts viatoNat. GoStringisList w8.
- Finite maps are
-
64-bit platform. The Go semantics assumes a 64-bit platform: the word-sized types
int,uintanduintptrare 64-bit (valuesw64).uintptr(go.UintptrSemantics,Perennial/Golang/Defn/Predeclared.lean) is an integer type likeuint64. Pointer/unsafe.Pointerto/fromuintptrconversions are not modelled: they are stuck. -
Integers and floats are bytes. A value of an integer or float type of
wbytes (go.IsPlainScalar,Perennial/Golang/Defn/Predeclared.lean) iswheap cells holding its little-endian bytes, allocated together (AllocBytes) at any address. Loads and stores go byte by byte, and are stuck on a cell that is not a byte. The integer atomics ofsync/atomicand the semaphores are byte atomics of their width (LoadBytes,SwapBytes,AddBytes,CmpXchgBytes,GooseLang/Lang.lean), at any address: a misaligned atomic is assumed not to tear (docs/KNOWN_LIMITATIONS.md).bool,string, pointers and the other non-plain values stay one opaque cell (docs/design/unsafe-memory.md, R1, R2 and R5). -
Plain types have gc's layout. A plain type (
go.PlainLayout,Perennial/Golang/Defn/Plain.lean: integers and floats, arrays of at least one plain element, non-empty structs of plain non-blank fields with no padding, and named types of these) has gc's size and field offsets, and at a positive address its element and field addresses are concrete (go.PlainSemantics):arrayIndexRef V i l = l + i·sizeandstructFieldRef V f l = l + offset. Other addresses stay abstract. Every allocation, and every heap points-to, is at a positive address. -
Model assumptions (overflow). Where Go panics on an overflow that a real program cannot reach (it would run out of memory first), the model assumes the overflow does not happen, with
assume(Golang/Defn/Assume.lean: anassumethat fails loops forever, so the proofs cover the executions where it holds):append's new length (sumAssumeNoOverflowSigned,Golang/Defn/Slice.lean). Known to be reachable, not yet fixed: with zero-size elements no memory bounds the length, and Go panics where the model assumes:x := make([]struct{}, 1<<62); x = append(x, x...)panics with "growslice: len out of range". A program with that bug verifies.
Only a length that must exist in memory may be assumed away; a length Go computes and checks before allocating is reachable. Overflows that a program can reach are not assumed away.
strings.Joinis one: Go sums the elements' lengths and panics before allocating anything, so a few gigabytes of aliased elements (make([]string, 1<<30+1)with a long separator) reach the panic. Known, not yet fixed:Joinis still a trusted model that omits that panic, andstrings.wp_Joinhas no premise excluding it (a fix that translates the realJoinis on branchjoin-translation; its model ofstrings.Builder.Stringmust first be made stuck where Go'sStringaliases the builder's buffer).sync.WaitGroup'sint32counter is another (twoAdd(1 << 30)calls panic in Go):WaitGroup.Addis Goose's translation, andWaitGroup.wp_Addasks its caller to bound the new counter by2^31. A caller whose counter is backed by one goroutine per unit discharges the bound with thread tokens (above):ntokens given < threadBound GF, and the client assumesthreadBound GF ≤ 2^31at adequacy time (ProgramLogic/ThreadTokensTest.lean,docs/PERENNIAL_PROOF_REFERENCE.md"Thread tokens"). -
Panics. A panic outcome is a value,
PanicV p, that unwinds every evaluation frame but that ofCatch e h k(GooseLang/Lang.lean);panic(p)steps to it, a function withdefers runs its body under aCatch(Golang/Defn/Defer.lean), andrecover()(Goose translates it only directly in a deferred function literal) reads and clears the panic. A postcondition receives panics too, a spec{{ P }} e {{ RET v; Q }}excludes them, and a goroutine must not panic (wp_fork:forkPost). The adequacy theorems (goose_adequacyand the grove/disk ones) require the main thread's postconditionφto exclude panics (hφ : ∀ p, ¬ φ (PanicV p), a pure fact aboutφ) and conclude that no thread of the execution has an unrecovered panic (UnrecoveredPanic,GooseLang/BoundedLang.lean): no thread isPanicV p, and none is unwinding a panic through frames none of which is aCatch, so a verified program does not die of a panic within the step bound. Run-time panics of builtins (Panic "msg") are stuck. Seedocs/PERENNIAL_PROOF_REFERENCE.md, "Panics". -
Trusted package-variable initializers. A package variable listed in a goose config's
trustedgets its initializerX.init(allocation included) fromPerennial/TrustedCode. Accepted for now, to remove later: five such models are trusted although goose translates the real source, so thereview-trustedrule R2 grades them REWORK; they were accepted (2026-10-19) to finish the standard library's init proofs, and later reviews and soundness work should translate them away:crypto/ed25519.cryptocustomrandandtime.asynctimerchan(no-ops; the fix translatesinternal/godebug, trusting its runtime hookssetUpdateandsetNewIncNonDefault, withinternal/bisectandinternal/godebugs),log.std(no-op; needsos.Stderr, soos's initialization),log'sinit(no-op; it writeslog/internal.DefaultOutput, and no ownership of another package's variable reaches an init proof: a program-logic gap), andcrypto/rand.Reader(stores a value of Go's dynamic type with a placeholder inside; needscrypto/internal/randanddrbg, whose init runs a FIPS self-test). The no-ops forerrors.errorType(a reflection type descriptor),math.useFMAandmath/big.hasADX(CPU features the runtime detects) model irreducible behaviour. Each model's docstring says why it is faithful. -
sync.Pool.GetandPutare trusted (TrustedCode/sync.lean): the real ones rely on runtime pinning to a P, pointer arithmetic andefacelayout reinterpretation (Perennial/Code/sync.tomlhas the goose run). They keep Go'sPut(nil)andNewhandling and replace the per-P pools by the Go modelgoose/model/pool, a lock-protected multiset in the pool's fieldlocal, wherePutmay drop its value andGetmay ignore the pool. That is more nondeterministic than gc's pools, victim cache and GC clearing. As with the channel and string models, the model's translation is part ofgo.Semantics(pool_sem). -
Method sets. Whether a type implements an interface (
go.typeSetContains, used by type assertions and conversions) is decided bymethodSet, an uninterpreted function of the semantics constrained bygo.MethodSetIs t msfacts (Golang/Defn/MethodSet.lean): "the method set oftis exactlyms". goose emits them, for each named typeTof a translated package, forTand*TinT.TypeAssumptions(method_set,ptr_method_set), from the Go type checker (go/types.NewMethodSet: value and pointer receivers, methods promoted through embedded fields with Go's depth and ambiguity rules, methods that are not translated, the same at every instantiation of a generic type); the semantics (InterfaceSemantics.method_set_no_methods) gives the empty method sets of the types that have no methods in Go (go.NoMethods). A method set is a property of a type's declaration that the type checker fixes, like its underlying type, which goose also emits: computing promotion in Lean would duplicate the type checker's rules, with nothing to check it against. A method is named by its Go id: an unexported method's name qualified by its package path ("context.cancel"), in method sets and in interface types (go.MethodElem) alike, since in Go an unexported method of one package never implements an interface of another. goose writes an interface type as all its methods (go/types' method set of the interface, embedded interfaces included), so thattypeSetContainsneeds no recursion through embedded named interfaces; embedded constraint elements (unions,comparable) stay as type terms. A type a signature mentions that the translation cannot name (a named type of a package it does not import, an alias it does not translate) is written as what it denotes (go.Named "<path>.<Name>" args, the aliased type), so that an interface type has the same term, up to unfolding descriptors and aliases, in every package that writes it: Go's type identity, which=ongo.GoTypemust agree with wherever interface types are compared (in signatures, as element types). Nothing is claimed beyond what is known: a method whose signature has a type goose does not support is an entry without a signature ((id, none): it exists), and the method set of any other type (a struct type literal with embedded fields, a type declared in a function, a type of an untranslated package) is unconstrained. Two limits of the model's type identity (=ongo.GoType) apply here as to type assertions: struct tags are not part ofgo.StructType, and the unexported field names of struct type literals are not qualified by package. goose loads code with the build taggoose: a method declared only in files excluded by it (or included only by it) would make the emitted method set differ from the real build's.go_type_set(Golang/Theory/TypeSet.lean) decidestypeSetContainsgoals. -
Generated code comes from goose. The translator in
goose/emitsPerennial/Code/**andPerennial/GeneratedProof/**; regenerate withetc/update-goose-new.pyrather than editing them by hand.
| Directory | Contents |
|---|---|
Perennial/Std |
general libraries (gmap, words, lists, bytes) |
Perennial/Algebra |
cameras and ghost-state algebra |
Perennial/GooseLang |
GooseLang: semantics, lifting, bounded-step layer, receipts |
Perennial/GooseLang/Ext |
GooseLang extensions (new primitives): grove |
Perennial/Golang/Defn |
the Go model |
Perennial/Golang/Theory |
its program logic and spec lemmas |
Perennial/Ghost |
ghost-state libraries |
Perennial/TrustedCode |
trusted (hand-written) models of Go code |
Perennial/Code |
GooseLang code (generated by goose) |
Perennial/GeneratedProof |
generated proof boilerplate (generated by proofgen) |
Perennial/Spec |
the specs of the proved Go functions, one per file |
Perennial/Proof |
their proofs, one per file, linked in <pkg>/Link.lean |
Run etc/lean-ci.sh + etc/lean-audit.py for the build and soundness audit
(sorry roots, axioms and opaques). etc/trusted-delta.py [commit | A..B]
prints what a change adds to the trusted base (semantics, goose, trusted
models, trust lists, assumes); the review-trusted skill
(.agents/skills/review-trusted/) reviews it.
- Follow the naming of the surrounding code (
wp_load,isMutex,ownSlice). Quote names that are not legal Lean identifiers with «» (e.g.«unsafe»,«_»). - Sealing:
def fooDef,@[irreducible] def foo := fooDef,theorem foo_unseal : foo = fooDef. To make an existing definition opaque, useattribute [irreducible] foo. - GooseLang code notation (
Perennial/GooseLang/Notation.lean):λ: "x", e,let: "x" := e1 in e2,e1 ;; e2,if: c then a else b,rec: "f" "x" := e, Go operatorse1 +⟨t⟩ e2etc. Method calls arercvr @!! T @!! m. - Everything lives in
namespace Perennial. - Every file is a module of Lean's module system:
module, then itspublic imports, then@[expose] public section, so that all declarations are public with their bodies exposed. - An unfinished proof is
sorry, with a comment saying what is missing if it is not obvious. Do not add newaxioms without discussing it first. - Notation:
#xisintoVal x;m !! k,<[k := v]> m,{[k := v]}work on bothgmapandList(on lists they arel[i]?andl.set i v); the set-valued domain of a map isdomSet m;go!"abc"is aGoStringliteral;l +ₗ iis location offset. - Equality on GooseLang syntax and
go.GoTypeis decided classically (noncomputable instance). - An extension (
GooseExt) has a name (by convention the path of the Go package it implements). Its operations are named by strings,ExternalOp ext op e(the extension's name and the operation's; an extension's step is defined on the operation's name), and its opaque values by its name and their encoding (ExtCode,extVal E v), so that neither the syntax nor goose depends on the program's list of extensions: the trusted model of a Go function implemented with an extension isExternalOp E.name "Op" ..., with no assumption, andElemExt E/HasExt E(proofs aboutE) are propositions, proved by computation when a program is closed off. Goose knows nothing of extensions. - An extension (
GooseExt) requiresPos.Countableof itsVal, andLoc,GoSlice,val,Expr,GoFunc,GoInterface,go.GoType, ... arePos.Countable(Perennial/GooseLang/Countable.lean, via an injection intoGenTree), so ghost state can store values containing code. - Check a file with
lake build Perennial.Path.To.Module(from the repo root). - The proof automation is compiled to native code:
lakefile.tomldeclares a second library,PerennialMeta, withprecompileModules, listing the modules that define tactics, elaborators, notation or simprocs and everything they import (about 150 modules, rooted at the generatedPerennialMeta.lean). After adding such a module, or changing the imports of one, runetc/meta-lib.pyand commit the result;etc/lean-ci.shandetc/land.sh preparefail until the list is up to date. Editing one of these modules relinks the library and rebuilds everything that loads it. The first build also compiles iris, batteries and Qq to native code (once).