Fil-C on Nix

Filnix wiki

nanofil/docs/rocq.md

Campaign 1: the core model in Rocq

November 2026. This note reports on Campaign 1 of formal.md, which builds on campaign.md: what exists, the decisions taken, what the transcription clarified, and what is left. The development is in nanofil/rocq/ and is checked by nix build .#checks.x86_64-linux.nanofil-rocq.

What exists

A Rocq 9.1 development, built with the Rocq and stdlib of the flake's pinned nixpkgs. It contains no Admitted, and no Axiom or Parameter outside Runtime.v's module type, which the interpreter's instance discharges; the Nix check fails otherwise, and Print Assumptions of judge is closed. Each file is a transcription of the interpreter and cites the predicate it copies.

File Transcribes Contents
Term.v ground Prolog terms names (temporaries, labels, symbols) as terms compared by ==, and term_eqb_eq, that == is equality; char_code/2
Maps.v library(assoc) name maps: a hash of the name, then an association list per hash, so a lookup's answer never depends on the hash; what each operation does to a lookup (nm_find_add, nm_find_remove, nm_find_fold_add)
Ieee.v ieee.pl binary32, binary64 and x87 extended as bit patterns: decode/4, round_ratio/5, ieee_binary/5, ieee_convert/4, ieee_compare/5, ieee_from_int/3, ieee_to_int/3
Syntax.v ir.pl's header tables classes, ops, values, instructions, jumps, blocks, functions, data, modules; op_arg_classes/4, evidence_preserving/1, ahead_transparent/1, local_objects/2
Values.v interp.pl's values cap (none, obj, ro, fn, ext), ptr, word; word_at_class/3
Memory.v interp.pl's Memory section regions, mem, cap_access_res/7, free_res/4, bytes and aux, fresh
Runtime.v alloc_base/6, free_res/4; runtime-axioms.md Campaign 2's Module Type Runtime, eleven axioms and three readiness obligations, and InterpRuntime, which proves them
Ops.v alu/4, additive_res/4, kind_res/8 integer and float ops, check and the reuse vocabulary, raw loads and stores, pointer-load and pointer-store protocols, va_arg
Semantics.v run/5's blocks, instructions, jumps, calls the configuration, step, step_rel, reachable; the variadic entry snapshot
Run.v initial_memory/4 module preparation, and Driver, a run over any Runtime's allocations and free
Machine.v — (Campaign 3) the machine of a Runtime: steps, the runtime's allocations, its actions at safepoints; ready kept by every step; each step a step_rel step or a safepoint action
Cfg.v cfg.pl func_cfg/2, cfg_idoms/2 (Cooper, Harvey and Kennedy), dom_children/3, dom_intervals/3, dominates_by_interval/3
Validate.v ir.pl's valid_module/1 valid_func, valid_data, valid_alias, valid_items, valid: uses, ops, instructions, dominance, locals, the evidence pass, the reuse rules, polls_every_cycle, data linkage and items, aliases
Dominance.v — (Campaign 3) what Cfg.v's dominator intervals mean: the entry is reachable, reachable blocks' successors are, and what dominates a successor dominates its predecessor, but for the successor itself; only the entry dominates the entry; dominance is antisymmetric
Interval.v offset_interval/8, op_interval/9 (ir.pl) the derivations of an offset interval as an inductive relation; every interval the walk answers is derived, and a derived interval holds of the values a run reads wherever the names it walks hold what their definitions computed and their single-definition operands are unchanged
Sites.v single_definitions/3, block_sites//1, definitions_dominate/3 (ir.pl) every definition with its site; a single definition has one; a use is dominated by its single definition, and no single-definition operand of a definition is defined again where the definition's site dominates
Identity.v — (Campaign 3) no step of a run loses an object: each is as it was, or freed, given that the runtime's free keeps identities; a check that passed then passes again, or traps out of bounds
Single.v single_definitions/3, definitions_dominate/3 (ir.pl); the steps of interp.pl in every frame of every run, each single definition whose site dominates the frame's point replays: its name holds what its instruction made from its operands' current values, in a memory that lay in the address space and that the current one persists from
Coverage.v — (Campaign 3) the coverage lemma: a raw access that covered_access/6 puts inside a passed check gives no unchecked
Evidence.v — (Campaign 3) the evidence invariant at an instruction and an edge: what a live fact means, every case of checked_inst_rest/7 keeps the pass's facts true, block entry keeps the meet, accesses the pass accepts give no unchecked
Walk.v — (Campaign 3) the evidence pass read back for the run-level invariant: block by block (pass_fold), position by position (walks), where it keeps facts (relies), a check_local's own fact
Steps.v — (Campaign 3) what one step does to the running frame, point by point: snapshot, block entry, instruction, return
Invariant.v — (Campaign 3) the run-level invariant stated: what holds at each point of a frame (at_pos, point_ok, inv), spares (no region changes where the pass keeps facts across a safepoint) with spares_any proving it of every runtime, and what each kind of step needs of it
Persistence.v — (Campaign 3) the evidence invariant over whole runs: every machine step keeps inv (one lemma per kind of step), and in every state of a run no raw access or raw call of a frame that is not doomed is unchecked, but by hull_covered
Chain.v offset_interval/8, guard_chain/3, pointer_distance/4, hull_covered/7 (ir.pl) what a frame reads off the single definitions that dominate its point; along every run, no access hull_covered accepts gives unchecked
Renew.v renew_source_op/6, hull_renewable/8, half_known/6 (ir.pl); renew_res/4, half_bound/6, half_res/4 (interp.pl) along every run, no renew, renew over a hull or half check the pass accepts gives unchecked
Guard.v guard_covers/5, limit_covers/5, lower_chain/3 (ir.pl); kind_res/8 (interp.pl) along every run, no renew_if or check_limit the pass accepts gives unchecked
Ahead.v ahead_justified/3, ahead_block/6, ahead_edge/5, ahead_step/5, ahead_insts/8 (ir.pl) the facts check_ahead's backward pass keeps, as functions of the point, and that its one sweep computes them
Doomed.v doomed_result/5 (interp.pl); raise_need/6, ahead_meet/2 (ir.pl) along every run, a frame a check_ahead doomed never steps to unchecked
Soundness.v inst_result/5, raw_access/5, valid_use/3 valid_no_unchecked: along every run of a checked module that starts ready, no step is unchecked
Transport.v — decoding a printed checked module, encoding outcomes, judge, judge_valid
Extract.v, extraction/main.ml — OCaml extraction, and a reader of Prolog terms

Covered: filc mode, the checked stage, classes w, l, p, a and f, and the float classes s, d and x with their arithmetic, comparisons and conversions; check, check_ahead (with the doomed state of schedule.md), renew, renew_hull, half_check, span_test with renew_if, limit with check_limit, check_local and named locals, check_function with raw_call (including the generic entry's argument and result conversions), raw_load, raw_load_pointer, raw_store, raw_store_pointer with their effect lists, flight, poll edges, direct call, libc free, and variadic functions (va_args, va_arg, the entry snapshot and the extra arguments of a call through the generic entry). What is left out is vectors, store, cas and atomics, setjmp, asm_fixed and touch. A function that uses anything else decodes to IOutside. It keeps its address, its fn capability and its term (so the validator can read its signature and its calls), but running or calling it is stuck, and the comparison reports such a run as outside.

The validator covers every item of a Fil-C module: functions in the subset, data objects and aliases. valid is None for a module with a function outside the subset. The context(Name, Records) items are not transcribed: they are provenance's records, which valid_contexts/1 checks and run/5 never reads, and the module the gates compare has none.

Routine smoke check and opt-in campaign

Rocq is experimental work, not a reason to turn every edit into a long release qualification. checks.x86_64-linux.nanofil-rocq is the quick push check, targeted at 1–2 minutes: build/extract/link the model, run web/rocq/tests/parse.sh on every theory, then compare outcomes and base validator verdicts on rocq/tests/*.ir, tests/filc/basic.ir, tests/c/ldouble.c and tests/c/prog/alias.c, with reference checks. The model's build runs parse.sh in place: one make compiles the theories and, as soon as a theory's imports are compiled, compiles its reprint alone against them, so the parse check runs beside the compile instead of after it. It exports the checked original sources and objects. The Ltac fixtures compile in their own projects; a source-only checkout builds the originals in a copy. No no-admit, parsing or typechecking gate is skipped. NANOFIL_MUTANTS=0 selects mutants.pl --base: it generates no mutations, only the unchanged items' whole-module verdicts. This is a smoke check, not rejection coverage or a soundness proof. In particular, dominance.ir's one-sweep-idom and always-true-dominance plants, and interval.ir's same-index plant, are detected by their mutant verdicts, not their unchanged base verdicts. They are covered only by the full campaign, not the routine no-mutants smoke check. This tradeoff is deliberate; a dominance or other soundness-relevant change calls for the opt-in evidence.

CI (.github/workflows/nanofil-rocq.yml) runs the quick root after each push that changes model, compiler/validator or gate inputs (its paths list); unrelated web styling, rendering and documentation edits skip it. Run it yourself before pushing such a change: nix build .#checks.x86_64-linux.nanofil-rocq.

The complete outcome and mutation matrix described below is checks.x86_64-linux.nanofil-rocq-full. Run it deliberately for a soundness-relevant change, with nix build or the workflow's manual full input. No nightly campaign is enabled.

All jobs retain timestamped progress through tests/watch.py; a corpus file warns at 60 seconds and fails at 300. Timeouts fail, never omit coverage. Independent outcome modes run in parallel, as the validator modes already did. Lua's whole-module mutant generation/judging uses independent item-index partitions after the program cases finish; every changed module is still judged whole, and inserted-item mutants occur only in partition zero. The union is checked against the serial stream. Model inputs exclude fixtures/harnesses/docs; the check excludes unrelated web/docs inputs. Outputs retain full logs per mode/program.

The full outcome gate

nanofil outcomes FILE (main.pl, a new command) prints the checked module that difftest would compare, then interp:run/5's outcome on each run line, as terms. rocq/agree.sh pipes that output into the extracted model. The model decodes the module, runs every line, and prints agree, disagree, outside or stuck_both for each; the script fails on any disagreement.

The full Nix campaign runs rocq/tests/*.ir once with the reference checks. It then runs tests/filc/*.ir, tests/c/*.c and forty generated Fil-C modules (tests/gen.pl SEED 10 filc, seeds 1 to 40) with and without -O, under each of the twelve --checks modes. All forty generated modules are Fil-C ones, and 1,291 of their 2,400 runs trap.

Corpus Lines that agree Outside the subset Disagree
filc fixtures and C tests, with and without -O (each --checks mode) 488 0 0
the gen modules, with and without -O (each mode) 2,400 0 0
Lua's own functions (rocq/lua.sh, with and without -O, each mode) 27 0 0
rocq/tests/reuse.ir (renew_if, renew_hull, each with and without a free) 4 0 0
rocq/tests/snapshot.ir (the variadic snapshot read, written, empty, freed) 5 0 0
rocq/tests/floats.ir (ties to even, a float read through the generic entry) 3 0 0

Before floats and variadics came in, 41 lines without -O were outside: floats, long double, variadics and float structs. None is now.

The gate can fail. I planted three bugs in the integer model, and later five in floats and variadics, each found by the gate and then reverted:

One planted bug is still not caught: generic_word keeping all 64 bits of an integer word read as s or d, instead of its low bits. The receiving function reads its parameter at its class, which takes the low bits again (value_x/5 calls word_at_class/3), and so does a caller reading a result. The difference could show only through a variadic callee with an s or d parameter reached through the generic entry, whose snapshot stores the parameter word as passed; no fixture has one yet.

The Lua test suite itself cannot run in the interpreter: Lua's functions take a lua_State and call libc (allocation, strings, setjmp for errors), which the interpreter does not model, and Lua's tests run natively in nix/lua.nix. What the gate takes instead is Lua's source in two ways. Lowered file by file, every one of Lua's 1,096 functions but one decodes into the subset; the one is luaD_rawrunprotected, which calls _setjmp. And rocq/tests/lua.c includes lctype.c, lobject.c and lstring.c, as Lua's onelua.c does, and runs the functions of theirs that need neither a state nor libc on the path taken: integer and float arithmetic (luaO_rawarith), integer numerals (luaO_str2num), the string hash, UTF-8 escapes and luaO_ceillog2. rocq/lua.sh preprocesses it against the Lua source nix/lua.nix builds, with the same flags.

Every reuse form is reached by the check passes over the corpus, but the C tests reach renew_hull (6 times) and renew_if (9) rarely, and seldom on a run that traps. So the filc mode of tests/gen.pl also draws a memory loop whose shapes are the ones the passes need: an access at a fixed offset in a loop with a call (the guard pass makes it span_test and renew_if), an access at an offset that grows with the trip count (the hoist pass makes it limit and check_limit), and after the loop two loads 8 bytes apart, a call and a load between them (the hull pass makes it renew_hull). The call is sometimes libc free of the slot, so a later renewal traps on a freed object, and offsets run past the slot. Over the forty modules the passes now emit 179 renew_if, 179 limit with 358 check_limit, 222 renew_hull and 412 check_ahead; before, none of the first three. Plain modules draw nothing new, so their corpus (nix/check.nix compares seed 1 byte for byte) is unchanged.

Four bugs planted in these forms are each caught by the gen modules alone: a failed guard's renew_if answering unchecked(span) (96 disagreements), a passed guard skipping the flag test (41, only after the free inside the loop came in), check_limit skipping its access test (269), and renew_hull skipping its access test (96).

reuse.ir, snapshot.ir and floats.ir are hand-written checked IR, so they run only with the reference checks: the check passes take only pizlonate's output, and a --checks=schedule run over reuse.ir is rejected by the validator, the expected result for input outside a pass's precondition, not a finding.

The validator and its gate

Validate.v transcribes valid_module/1 for code, clause group by clause group. A function is valid when its uses, ops, instructions, jumps and phis are well formed, every use has a dominating definition (by the dominator tree's intervals, Cfg.v), named locals are declared once in the entry block, and, in a checked module, stage_func/6 holds: the evidence pass over the blocks in order, limit_reads/2, and polls_every_cycle/1 (by repeatedly removing blocks with no unpolled edge into what is left). A data object is valid when its alignment is a power of two up to 4096, its linkage is one a Fil-C module allows (with the items extern(weak) and init(Kind, Prio) ask for), its float items are in range, its strings hold bytes, and it has no item(l, sym(_, _)). An alias is valid when its linkage is export or weak, no function or data shares its name, and its target is a function, or data of a linkage an alias may name that no function calls through the alias. ir.pl throws at the first problem; the model returns false, so it follows ir.pl's clause groups and not its order of evaluation.

Data items the interpreter does not lay out decode to ItemOther, and linkages it does not know to LOther, each keeping its term: the validator reads them, and the interpreter's layout is stuck on them.

Three places in ir.pl depend on how Prolog evaluates, and the model copies them, as Validate.v's header records: offset_interval/8 memoises successes but not failures under a depth limit of 32; pointer_distance/4 commits to the first shared base (a cut); and op_interval/9 commits after its heads, so and with a constant tries its left operand first.

The gate is rocq/valid.sh. For each file, nanofil outcomes prints the checked module, and rocq/mutants.pl prints, for each item, the item as it is and up to 150 mutants. A function's: a check deleted or moved, a check's bounds, rights or alignment changed, a raw access's offset moved by one, a free inserted between evidence and its use, a reuse form given another proof, a poll edge removed. A data object's: its alignment, its linkage, an item of each shape the validator looks at appended, a byte, a long or an x constant prepended (which moves every pointer item after it), the object renamed (also to another item's name) or deleted. An alias's: its linkage, its target and its name changed, or the alias deleted. And up to 150 items inserted: an alias of each kind of target, under a fresh name and under the name of a function the module calls without defining, a data object of each linkage, and a data object under the first function's name. valid_module/1 judges each changed module, and the model must reach the same verdict. Plain modules are skipped, and so is a mutant that would make its module plain.

The corpus is the matrix's Fil-C fixtures and C tests; forty modules of ten random functions each from tests/gen.pl, whose filc mode addresses its memory through p values, reaches outside its slot and draws the memory loop above; and, with the reference checks only, the 16 C programs of tests/c/prog that declare an alias, a weak symbol, thread-local data or a constructor, which bring the corpus's aliases and its kinds of data linkage. Programs with a // cflags: line are left out, since they compile as progtest.sh does and not as cc-ir does. The other programs are too slow for the check, because each mutant is judged on its whole module; one run over 169 of them agreed on all 73,330 verdicts it could judge.

Corpus Verdicts that agree Outside the subset Disagree
filc fixtures, C tests and gen modules, without -O (each --checks mode) 64,589–68,260 253–256 0
the same with -O 45,541–48,193 253–256 0
the 16 tests/c/prog programs (reference checks) 9,505 3,012 0
Lua's own functions, rocq/lua.sh (reference checks) 6,345 0 0
rocq/tests/reuse.ir, snapshot.ir, floats.ir, dominance.ir, interval.ir (reference checks) 111, 145, 46, 41, 30 0 0

The counts were measured before the mutants for the three data decisions below (prepended items, renames onto other items' names, data under a function's name) were added; those add a few verdicts per item, and the full campaign's receipt for that change had no disagreement.

The mutants test rejection as well as acceptance: on basic.ir, valid_module/1 judged 413 of them invalid and 210 valid. In the four C programs with aliases or weak symbols, every kind of data and alias mutation met both verdicts; for instance 38 retargeted aliases were invalid and 35 valid, and 296 changed data linkages invalid and 363 valid. A mutant is outside when it is itself outside the subset, when it changes its function's signature and a function outside the subset calls it, or when it changes data or an alias that a function outside the subset names, since the model cannot judge that function.

The gate can fail. I planted eight bugs in Validate.v, each found by the gate and then reverted: a covered access that ignored its size; evidence that survived an instruction that does not preserve it; a hull allowed below its lower proof; cycles that needed no poll; data aligned up to 8192; item(l, sym(_, _)) allowed; calls through an alias of data allowed; and an extern(weak) declaration with items.

Decisions

These are formal.md's early decisions as they turned out in the code:

What the transcription made clearer

What transcribing the validator made clearer

A freed snapshot traps at its renew

freed_snapshot frees the variadic snapshot (free of va_args at offset 0) between a check of it and a renew of that check. ir.pl accepts the module, and the run ended unchecked(span): a valid module whose run is unchecked, a counterexample to Campaign 3's valid_no_unchecked as stated, found in the Prolog itself. The model agreed with the interpreter, so it was not a disagreement.

The interpreter was stricter than native code, not the other way round. The emitted renew (filc_amd64.pl's fused(renew(...)), which renew_hull and renew_if's fast path share) tests the free flag in the object's flags byte through its lower, whatever the capability's kind, and the pinned runtime's object_for_deallocate refuses special, global and mmap objects and interior pointers but not READONLY ones. So native Fil-C frees the snapshot and traps at the renew. renew_res/4 now treats a freed ro object as it treats a freed obj one, and Ops.v's renew_res says the same: the run traps out of bounds, as freed_snapshot_check does. An Oracle review of the change found the callers (renew, renew_hull, renew_if) consistent, the trap order unchanged (a freed object fails its bounds before read-only is tested, as native reports FREE first), and no other obj-only assumption about freeing; only the snapshot is a freeable ro object, since read-only data is not heap and its free is invalid_free.

Disagreements

None so far, in the outcome gate or the validator gate, floats and variadics included. No fixture was needed, and the Prolog has not yet been contradicted. Making the comparison work needed two changes on the OCaml side, neither of them semantic: a gas limit in the driver that was too small for loops (now 2^60 steps, so the run's own fuel always ends a run first), and atoms containing UTF-8 bytes in a reader.

Campaign 3: the machine of a runtime

Machine.v is Campaign 3's first milestone. Machine R runs Semantics.v's steps over a runtime R: a step that allocates nothing, an allocation at R's alloc, or R's safepoint action, which may happen before an instruction where R.at_safepoint holds and before a poll edge, and nowhere else. A call of free is the genv's ge_free, which Run.v's Driver sets to R's free. Two theorems, no admits:

Decisions and findings:

The coverage lemma

Coverage.v is Campaign 3's second milestone, no admits, checked in about a second. covered_access_not_unchecked: where ir.pl's covered_access/6 says a raw access instruction (raw_load, raw_load_pointer, raw_store, raw_store_pointer) lies inside the check that yielded its address operand, and that check passes in the memory the access runs in, inst_step does not stop the instruction with unchecked. It is arithmetic over cap_access_res/7 in two steps: cap_access_covered (a passed check of [A+Lo, A+Hi) passes for every part of it at an alignment the check's implies and with rights it allows) and covered_access_checked (the raw access's own check is such a part).

Decisions and findings:

The evidence invariant, instruction by instruction

Evidence.v proves ir.pl's evidence pass sound one instruction and one edge at a time. No admits; it checks in about 7 seconds, most of it the two case splits over the check-like ops. A live fact live(Q, P, Lo, Hi, Rights, Align) means at run time (fact_holds): Q holds a pointer word, P evaluates to the same word, and the access check of that pointer passes in the current memory. A check reads only the memory's regions, so any step that changes no region keeps it.

Decisions and findings:

Persistence.v carries these along whole runs (next section).

The evidence invariant over runs

Persistence.v proves, with no admits, that in every state a run of Machine R reaches from a checked module's function, started ready, the facts the pass computed for the running frame's position hold. Its theorem run_access_not_unchecked: a frame that is not doomed runs no raw access and no raw call to an unchecked outcome, unless the pass accepted the access by hull_covered. With Walk.v, Steps.v and Invariant.v it checks in about 5 seconds.

Decisions and findings:

Not yet: hull_covered, the reuse forms' own rules (a renew's unchecked(span), a half check's, a limit's, unchecked(alias)), the doomed frame (check_ahead), and then valid_no_unchecked itself, which assembles these with the initial readiness hypothesis. The reuse forms rest on dominance, below.

Dominance

The reuse forms' rules read single-definition names along flight chains (guard_chain/3, pointer_distance/4), and a chain is a relation between the values of two names: a flight's value and its base's. In a loop the base is defined again on each iteration, so the relation holds at a point only because the flight's definition dominates it, and the base's dominates the flight's (definitions_dominate/3). The proof that the reuse forms are sound therefore needs the validator's answer to "does A dominate B?" to mean what it says on the machine's runs.

Dominance.v proves it, with no admits, of block_intervals for every function: of the dominator tree and intervals ir.pl computes, not of a corrected copy. It checks in under two seconds.

Decisions and findings:

Offset intervals

The reuse forms bound a flight's offset from its base by offset_interval/8, a memoized walk with a depth limit through the single definitions of integer names: a narrow ext or load, copy, and with a mask, urem, shl and mul by a constant, add, and sub of a constant. Interval.v proves, with no admits, in under two seconds:

Decisions and findings:

Where a name is defined

Sites.v joins the two ways ir.pl reads a function's definitions: the list single_definitions/3 filters, and the sites block_sites//1 records for definitions_dominate/3. No admits.

Identities persist

The reuse forms trust a check that passed earlier. That is sound only if the checked object is still there at the reuse: as it was, so the check passes again, or freed, so it traps (persists_access). Identity.v proves it of every run that starts ready (mreachable_persists): a step that allocates nothing keeps every region or frees it in place, an allocation adds an object at a base no object had, and the runtime's safepoint keeps every identity (rooting). No admits.

Decisions and findings:

The single-definition invariant

Each reuse form reads a relation between single-definition names: a flight and its base, a proof and the pointer it checked, a span test or a limit and the pointer it read. Single.v proves the relation holds along every run. In every frame (the running one unless doomed, and each waiting caller), each single definition whose site dominates the frame's point replays (replays). Its name holds what its instruction's own step made, in some earlier frame that agrees with the current environment on the instruction's single-definition operands, and the current memory persists from the memory that step left. A check that passed then passes again or traps (persists_access). A flight's capability is its lower donor's. mreachable_sinv proves this for any runtime whose free keeps identities, and interp_sinv proves it for the interpreter with no hypothesis but a ready start. No admits.

Decisions:

Reading single definitions

Chain.v turns Single.v's invariant into what the reuse forms read. An offset interval the validator derives (offset_interval/8) holds of the value the frame reads, where its names dominate the point (interval_at). Each element of a pointer's guard chain (guard_chain/3) that is a single definition holds of the pointer (guard_chain_sound). So two pointers that pointer_distance/4 puts D apart share a capability, with addresses D apart modulo 2^64 (pointer_distance_sound).

The first reuse form is proved from these. A raw access that the pass accepts by hull_covered/7 gives no unchecked (hull_access_not_unchecked). The precondition is that the live facts hold and their pointers' single definitions dominate the point; the evidence invariant now carries both, so along every run there is no hull_covered exception left (run_access_checked, interp_access_checked).

No admits.

Decisions:

Renew and the half check

Renew.v proves the next three reuse forms. A renew and a half check trust Q1, the proof of an earlier check of P1, a single definition that the pass records as a renew source (renew_source_op/6). A renew of P checks the whole span of P only if P's span lies inside Q1's at the distance pointer_distance/4 finds. A half check tests one bound natively and takes the other from Q1's span (half_known/6). The interpreter makes the run unchecked where that trust was false: Q1 of another capability is unchecked(alias); a renew whose access check fails, but not on a freed object, is unchecked(span); and a half check whose native test (half_bound/6) disagrees with the whole check is unchecked(half). A renew over a hull (renew_hull) trusts two sources, QL and QH, whose spans bound P's from below and above (hull_renewable/8). Along every run, a frame that is not doomed runs none of the three to unchecked (run_reuse_checked, and interp_reuse_checked for the interpreter, closed).

The proof has three steps:

The half check needed three facts that nothing stated before:

No admits. Decisions:

Guards and limits

Guard.v proves the two reuse forms whose evidence is not a check.

A renew_if of P with guard G trusts a span_test of N0 that G names (guard_covers/5). Where G is 0, it checks, and a check is never unchecked. Where G is not 0, the span test found N0+[GLo, GHi) inside N0's object, and P's guard chain puts P's span inside that one. The renewed access then lies inside the object as it was (guard_inside), so in a memory that persists it passes, traps read-only or misaligned, or finds the object freed (renew_inside): never unchecked(span).

A check_limit of P with U trusts a limit of N0 whose size is the check's span (limit_covers/5), and P's lower chain reaches N0 (lower_chain/3). Each step of a lower chain is a flight, whose capability is its lower donor's, so P has N0's capability (lower_chain_sound). The limit then matches, and the instruction checks: never unchecked(limit).

Along every run, a frame that is not doomed runs neither to unchecked (run_guard_checked; interp_guard_checked, closed). No admits, and no new hypothesis or runtime fact.

Decisions:

Facts ahead

Ahead.v reads back check_ahead's backward pass, the first step toward its doomed frame. ahead_justified/3 sweeps a function's blocks once, last first, and keeps per point the needs: a base N and an offset H that every path from the point reaches by a raw access through N, before any poll edge, back edge or end. The file states those facts as functions of the point: block_exit (the meet over a block's edges, each reading its target's entry facts from the final table) and facts_from (the instructions after a point, walked backward). It proves the sweep computes them: each block's entry facts are what its instructions make of its exit (ahead_entry), and each accepted check_ahead is accepted with the facts after its own access (ahead_accepted).

Decisions:

The doomed frame

Doomed.v proves the last reuse form. A check_ahead whose early bytes fail yields its pointer anyway and dooms the frame (doomed_result/5). The interpreter then runs it to unchecked(ahead(Why)) if it reaches a poll edge, a return or an instruction that is not ahead-transparent before an access fails. The theorem run_doomed_checked says this never happens in a checked module's run: a doomed frame never steps to unchecked of any kind. interp_doomed_checked states it under the interpreter's runtime; like the other run theorems, neither takes a hypothesis about safepoints.

The invariant, dinv, says what holds of a doomed frame (doomed_ok):

Every step keeps it (mstep_dinv). The check that dooms the frame starts it from the pass's invariant and the single definitions in scope (doom_start). An ahead-transparent instruction either keeps the need (step_keeps, raise_keeps), or is the access that meets it, which fails by the arithmetic of "A need past 2^64" below (need_access_fails) and ends the run trap(early). A jump keeps it only through a later edge with no poll, which the meet guarantees when its need is non-empty (doomed_jump). A doomed frame allocates nothing, so a fresh step is impossible (doomed_not_fresh), and a safepoint frees only what keeps the doom's memory (doom_mem_persists). Given the invariant, doomed_step_checked rules out each unchecked reason: ahead, because the need says an access comes first; alias and limit, because renews, hulls, half checks and limits ask only the capability of their source, which the weak scope gives (renew_not_alias, hull_not_alias, half_not_alias).

Decisions:

No run is unchecked

Soundness.v assembles Campaign 3's theorem. valid_no_unchecked says: in every state of a run of a checked module's function that starts ready, step never answers Done (Unchecked w). Its proof reads each place an unchecked can come from and hands it to the run theorem for that place:

Source Run theorem
a frame a check_ahead doomed Doomed.v's run_doomed_checked
a raw load, store or call Chain.v's run_access_checked
a renew, a renew over a hull, a half check Renew.v's run_reuse_checked
a renew_if, a check_limit Guard.v's run_guard_checked
the runtime's free Runtime.v's axiom free_checked

A frame that is not doomed steps to unchecked only where its next instruction's result is (step_unchecked_inst): block entry, jumps and returns give no such outcome. Of instructions, only a raw access, a raw call, a reuse form, or a call of free can (inst_unchecked_source); every other op either succeeds, traps or is undefined behaviour. The validator reads a raw access only when its address is a temporary (raw_access/5), and raw_tmp shows a checked module has no other: valid_use/3 never reads a global at class a nor a constant at a proof class.

interp_valid_no_unchecked states it under the interpreter's runtime with no hypothesis beyond the run starting ready; Print Assumptions reports it closed.

What the theorem covers and what it does not:

Decisions:

A need past 2^64

Planning the proof for check_ahead's doomed frame turned up a module ir.pl accepted and the interpreter ran to unchecked(ahead(out_of_bounds)), where the reference program returns normally: a counterexample to valid_no_unchecked, and a false early trap natively.

A check_ahead of P = B + K is justified when every path ahead reaches an access through B ending at B + H, with H at least K + Hi (ahead_insts/8). The rule kept K and H within 2^62 of 0 (near_offset/1), so each is a true signed distance from B, but not K + Lo. In tests/unit.pl's case, B = a - (2^62 - 8) and P = B - 2^62, so K = -2^62, and the check's Lo is -2^63: P + Lo is a + 8 modulo 2^64. A later access of B's bytes [2^62 - 8, 2^62) has H = 2^62, far past K + Hi = -3 * 2^62 + 16, but its address is a itself. The check's start and the need's end, 2^64 - 8 apart as integers, wrapped onto each other: the access passed, and the doomed frame reached its return.

The fix follows the existing rule: ir.pl's ahead_insts/8 and widened/5 in schedule.pl, which emits check_ahead, both ask that K + Lo be near too (Validate.v transcribes it). The access a path runs ends at some H' of at least K + Hi and at most 2^62 (raise_need/6), so H' - (K + Lo) bytes after P + Lo, at most 2^63; P + Lo lies in an object below 2^48, so neither address wraps: where [Lo, Hi) failed, the access fails too. Bounding the need instead would not do, since the meet over a branch keeps the least need while the other arm's access may end at 2^62. Oracle reviewed the fix. The pass widens the same 55 checks in tests/c with the bound as without it (docs/schedule.md, review finding 3).

An object past 2^64

rocq/tests/huge-object.ir allocates an object of 2^64 - 2^32 - 80 bytes and then one of 256, which the interpreter's allocator (Next only adds) places 64 bytes below 2^64. A check of the second object's bytes [0, 128) passes, and a raw load at offset 64, which the check covers, wraps to address 0 and runs unchecked(out_of_bounds). ir.pl accepts the module, and the model agrees with the interpreter: a counterexample to valid_no_unchecked over the interpreter's runtime, found by the coverage lemma's proof rather than by a test.

The interpreter was more permissive than native code. The pinned Fil-C refuses a heap object of more than 2^48 - 1 bytes before allocating ("filc safety error: attempt to allocate object that is too big", filc_prepare_allocate_object), and one within that limit that the address space cannot hold ("filc safety error: out of memory", at 2^47 here), so no native object ends above 2^64. The fix, Campaign 3's third milestone, follows native code:

Initial readiness is a hypothesis. A valid module's initial memory need not be ready. ir.pl accepts zero(N) data of any size, and initial_memory/4 lays data out from 65536 with no limit, so data can reach past 2^32, where heap objects start and overlap it, or past 2^64, where a covered access wraps again: with data(big, export, 16, [zero(2^64)]) and a flight to 64 bytes below 2^64, valid accepts the module and the model runs it unchecked(out_of_bounds), as the review showed. The interpreter cannot build such data (item_bytes/3 makes every zero byte and overflows its stack), so the gates do not see it. Natively an 8 GiB global compiles and runs.

mbrock's decision: valid_no_unchecked takes "the run starts ready" (R.ready of the runtime's start and the initial memory) as an explicit hypothesis, as the machine's theorems already do, and the IR is unchanged. Bounding filc data in valid_module/1 at 2^32 was rejected: it refuses static data the native toolchain accepts.

The planned long-term fix is to start the heap above the module's data: the runtime's start state would depend on the module (for the interpreter, Next from the end of the data layout, rounded up, rather than stack_base/1's fixed 2^32), so that every valid module whose data fits the address space starts ready, and the hypothesis becomes a lemma. Data past the address space would then be refused as the runtime refuses an allocation it cannot hold. That changes interpreter semantics and the module type's start, so it needs an Oracle review.

Objects end below 2^48

objects_fit said every object ends at or below 2^64. That is enough for one check: a covered access does not wrap (Coverage.v). It is not enough for hull_covered/7, which joins the spans of several checks of one object, related through pointer_distance/4, with each offset within 2^62 of the access's base (near_offset/1), all modulo 2^64. Two spans of an object are on the same side of the wrap only if the object is smaller than about 2^63. So a runtime could be ready in a memory with an object [2^16, 2^64 - 2^20), such as a large zero(N) data item, and p 8 bytes below its end. Then a check of p+[0, 8) passes, a check of [0, 8) through a flight at p + 2^62 - 8 passes after the address wraps, inside the object, and an access at p+8, between the two spans, lands at the object's end: hull_covered accepts it, and the run is unchecked(out_of_bounds). valid_no_unchecked over every runtime was false as the axioms stood. The counterexample cannot arise in the interpreter, whose ready keeps every object below 2^48, nor natively. An end bound below 2^63 is enough for the hull, even for spans whose hi is below their lo.

The same bound supplies what half_known/6 and hull_renewable/8 assume of a checked address, that it is a user address below 2^56: under the old axiom, a half check testing only the upper bound of a huge object could have wrapped below its lower.

mbrock's decision: tighten the axiom to native's bound. objects_fit now says every object ends at or below 2^48, libpas's address space (PAS_ADDRESS_BITS), which is what axiom-address.c tests, for heap objects, static and initialized data and an address-taken local; native Fil-C also stores object addresses in 48 bits (FILC_OBJECT_AUX_PTR_MASK). InterpRuntime proves it unchanged: Next is at most 2^48. The coverage lemma holds a fortiori. The alternatives were a hypothesis on the hull theorem, and a width bound in ir.pl's hull rule, a validator change with no native motivation. Initial readiness stays a hypothesis; with this bound it also keeps a ready module's data below 2^48.

Next

  1. Starting the heap above the module's data, so that the initial readiness hypothesis becomes a lemma.

Inquiries touched: none closed. The integer-at-p case is a small input to the integer-to-pointer inquiry.