Skip to content

363 linker envelope numeric - #426

Merged
0xGeorgii merged 16 commits into
mainfrom
363-linker-envelope-numeric
Aug 18, 2026
Merged

363 linker envelope numeric#426
0xGeorgii merged 16 commits into
mainfrom
363-linker-envelope-numeric

Conversation

@0xGeorgii

@0xGeorgii 0xGeorgii commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Confidence Score: 5/5

The PR appears safe to merge; no concrete changed-code defect remained after checking the memory-layout, linker-remapping, provenance, and proof-resolution paths.

The configurable layout is consistently shared and ABI-gated, newly merged global state is constrained and remapped, unsafe provenance cancellation paths fail closed, and linked proof applications are checked against the final module for ambiguity and arity.

Important Files Changed

Filename Overview
core/wasm-linker/src/merge.rs Extends merge planning to reconcile closure-used memory and globals while remapping admitted global operands consistently.
core/wasm-linker/src/provenance.rs Introduces coefficient-parity and correlation-aware provenance for scaled pointer arithmetic with fail-closed cancellation handling.
core/wasm-codegen/src/hassert/translate.rs Resolves bound external declarations by identity and emits their shared post-link symbolic names into proof obligations.
core/wasm-to-v/src/translator.rs Resolves linked application symbols with ambiguity and arity checks and adds Rocq lowering for integer width-changing operations.
core/cli/src/main.rs Resolves one checked MemoryLayout and supplies its stack budget and full layout to analysis and codegen respectively.
apps/infs/src/commands/project_build.rs Gates forwarding of non-default memory settings on the additive compiler ABI capability.
core/analysis/src/rules/stack_depth.rs Uses the configured stack budget and shared frame-alignment constant while conservatively estimating frame sizes.
core/wasm-to-v/rocq-stub/wasm/datatypes.v Expands the local Rocq contract stub with constructors required by the newly translated integer operations.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Manifest[Inference.toml / CLI memory options] --> Infs[infs compatibility gate]
  Infs --> Infc[infc resolves MemoryLayout]
  Infc --> Analysis[A036 stack analysis]
  Infc --> Codegen[WASM codegen]
  Analysis --> Codegen
  Codegen --> Linker[Static linker]
  External[External integer-only WASM] --> Linker
  Linker --> Linked[Linked WASM with remapped globals and names]
  Linked --> Translator[wasm-to-v]
  Translator --> Rocq[Rocq module and obligations]
Loading

Reviews (1): Last reviewed commit: "Correct documentation that asserts prope..." | Re-trigger Greptile

Context used (5)

…363)

Real toolchain output uses integer width conversions and sign-extension
pervasively; both families were refused at every layer of the pipeline on
the premise that the wasm-verifier proof contract modelled neither. That
premise no longer holds: the contract gained interpreted `T_unop`/`T_cvtop`
terms with semantic rules discharged inside its soundness theorem, so the
eight operators now have real lowerings and are admitted in lockstep across
the four places that refused them.

Sign-extension is not a conversion. The proof contract spells
`i32.extend8_s` and its four siblings as `BI_unop t (Unop_extend n)`,
beside `clz`/`ctz`/`popcnt`; only `i32.wrap_i64` and `i64.extend_i32_s/u`
are `BI_cvtop`. The WASM mnemonics group them misleadingly, which is why
they were retracted alongside the conversion block in the first place.

`Unop_extend`'s argument is the source width in bits, not bytes. The
contract's typing side condition ignores the argument entirely while its
denotation divides by eight, so a byte count elaborates, satisfies the
side condition, and denotes the constant zero for every input. `coqc`
cannot see that class of error at all — measured, not assumed: a planted
`Unop_extend 1%N` type-checks clean. Two byte-comparison tests are the
whole guard, and each names the other so neither is removable as
redundant.

The four layers, all required together to keep the allow-list contract
("an allow-listed operator is one the translator can render") true at the
Rocq level rather than only at the Rust one:

- the vendored stub gains `Unop_extend`, a `cvtop` inductive, and
  `BI_cvtop`. `cvtop` declares only `CVO_wrap`/`CVO_extend`, since the
  remaining six each need a float number type the stub deliberately omits;
  a stub narrower than the backend keeps an accidental emission an
  unbound-constructor error instead of a silently type-checking term;
- `wasm-to-v` lowers all eight and scopes its rejection to conversions
  that name a float, with a message that says why;
- the linker allow-list admits all eight as pure numeric operators;
- `SUPPORTED_WASM_FEATURES` gains `SIGN_EXTENSION`, which the validator
  refused before any body was scanned. `SATURATING_FLOAT_TO_INT` stays
  out: its operands are floats, so admitting it would recreate the
  allow-listed-but-unlowerable divergence this gate exists to close.

Verified against the real backend rather than the stub alone: under Coq
8.20 with WasmCert-Coq v2.2.0, the emitted module elaborates and the
upstream reflective checker accepts the emitted body, confirming cvtop
argument order, arity, and the bit-width literal against the real model.

Inference codegen emits none of the eight, so every committed `.wasm` and
`.v` golden is byte-identical; only an external `.wasm` that previously
failed to link is affected.
Tier B's admission is easy to read as a bounds guarantee — "the closure
loads or stores through addresses the caller supplies" — and it is not
one. The analysis proves every address *flows from* a caller parameter;
it says nothing about the address staying inside the region the caller
meant to grant, and it cannot, because it carries no sizes. Its lattice
has three tags and no numeric component, so "how far from p" is not
representable.

Admitted by design, and all verified linking today:

  store at p + 1048576                    a constant displacement
  store at p + q                          two parameters, no constant
  store at p + p    (= 2p)                nowhere near p
  ptr = p; loop { store ptr; ptr += 4 }   off the end of any buffer

`Param + Param` is a deliberate test-pinned admission: the caller supplied
both operands, so under the derivation property their sum is the caller's
business. That is also why bounding the constant displacement buys nothing
— the cheapest way to address arbitrarily far from p uses no constant at
all, so a cap would be a bound on the one form that is not the problem.

What limits the damage today is not this analysis but the main module's
fixed single page: an out-of-region address is usually out of bounds and
traps. That is an accidental backstop rather than a guarantee, and it
weakens as the declared memory grows past what the program uses — which is
what makes it worth writing down now, ahead of configurable linear memory.

Documentation only; no behavior change and no code line touched.
#363)

Every artifact a real toolchain produces carries an lld-synthesized
`__stack_pointer` mutable global, and a `std` build also carries an empty
`(table 1 1 funcref)`. A leaf integer function reads neither. Tier C
rejected both on the mere presence of the declaration, so essentially no
real external could be classified at all — while the effect flags that
describe what the closure actually does were already computed, and already
false.

Globals and tables now gate on `effects.uses_*`. Data deliberately does
not: an active data segment initializes the shared memory at instantiation
whether or not any instruction names it, so dropping an unused one changes
program semantics. A passive segment would be inert, but the parser keeps
only `data_count` and discards segment kinds, so the two cannot be told
apart and both stay rejected.

This is safe because effects are closure-scoped — accumulated over exactly
the function set the merge later walks — so an admitted closure contains no
global or table operator at all, and no stale index can survive into the
output. The property is load-bearing for globals, where a leaked
`global.get` would rebind to the main module's own global and pass
post-merge validation with a wrong value and no diagnostic. For tables it
is only fail-safe: no table section is emitted, so a leaked table operator
fails validation as an unknown table.

Two tests carry that argument, and each catches what the other cannot. A
biconditional beside `check_operator` pins that every admitted operator
naming a global or table sets the corresponding flag. A closure-level scan
pins that the merged function set and the scanned function set agree —
verified by neutralization: making `local_func_indices` over-approximate
while leaving `effects` correct fails it with the offending operator named,
and left the whole suite green before the fixture that reaches a sibling
body outside the closure.

The reason strings are split so each names what actually fired; they
previously conflated defining a global with reading one, and a table
operator with an element segment, neither of which implies the other.

This relaxes the global and table gate only. A stock
`wasm32-unknown-unknown` artifact still fails memory reconciliation against
the compiler's fixed one-page maximum, and against a memoryless main it
widens the output memory to the external's declared size even when the
closure never touches memory. Both are pinned by fixtures as current
behavior, not endorsed, and are the subject of the configurable-memory
work.
…#363)

Every merged external's memory section was folded into reconciliation
unconditionally, so a module's declared page count became a fact about the
merged output even when no merged body could observe it. Both consequences
were wrong in the same way. Against a memoryless main, a leaf `i32.add`
over a 17-page `wasm32-unknown-unknown` artifact produced a merged module
declaring 17 pages — written into the `.wasm` and restated in the paired
`.v` as the machine the proof is about, introduced by a pure function.
Against the compiler's own `(memory 1 1)`, that same pure function was
rejected outright over pages nothing would have touched.

Dropping the declaration is unobservable because `uses_memory` is
closure-scoped: when it is false, no body in the merged closure holds a
load, a store, `memory.copy`/`fill`/`init`, or `memory.size`/`memory.grow`.
The last two are worth naming — they yield or extend a page count rather
than addressing a byte, so they read as unrelated to a memory's limits when
they are in fact the operators that observe them most directly. Both count
as use.

Two guards deliberately stay outside the new gate. An unsupported memory
shape — memory64, shared, a custom page size — is still rejected for every
declared external memory, adopted or not, so that rejection stays absolute
rather than becoming conditional on an effect flag. And a closure that uses
memory when no module declares one is still rejected unchanged.

This partly supersedes the tier-gate change: a stock lld artifact clears
the tier gate and then fails reconciliation only when its closure actually
uses memory. A pure leaf now links against a stock compiler main with
main's single page kept as-is. The reconciler's refusal to relax a main
module's pinned maximum is untouched, so a memory-using external over a
multi-page module still fails; configurable linear memory is separate.

The two fixtures that pinned the old outcomes as behavior recorded rather
than endorsed are updated to the new ones, which is what they were written
for.
The compiler's memory shape lived in two constants: `STACK_SIZE` in
`core/wasm-codegen/src/memory.rs` and a `STACK_BUDGET_BYTES` in
`core/analysis/src/rules/stack_depth.rs` whose own doc comment recorded
that the two "must stay in sync" and were duplicated deliberately,
because the crates share no dependency that could hold the value. That
arrangement is safe only while the number never changes; a configurable
stack turns a stale A036 budget into a rule policing a shadow stack the
artifact does not have — rejecting programs a larger stack accommodates
or, in the dangerous direction, accepting programs that overflow a
smaller one.

`MemoryLayout { pages, stack_size }` on `CodegenOptions` is now the
single source for the memory section, the `__stack_pointer` initializer,
and the per-frame size assertion, and `AnalysisOptions` carries the
budget to every rule through `Rule::check`. Neither crate gained a
dependency: each keeps its own representation, as `EmitFeatures` already
does for the feature vocabulary, and the equality of the two defaults is
asserted from the test crate that depends on both — the guard the old
comment asked for in prose and had no way to state.

`validate` rejects a layout that cannot describe a real memory. Beyond
the obvious bounds it enforces that the memory and the stack together
span no more than the 32-bit address space, because a stack overflow
wraps to an address at least 2^32 minus the stack size, and that address
must stay past the end of memory for the overflow to trap rather than
write into it. That headroom is what a one-page memory supplied
implicitly and a larger one does not.

No user-facing knob yet: the manifest table and compiler flags are
separate work. Every emitted artifact is byte-identical under the
default layout, which all 212 golden `.wasm` files confirm.
`MemoryLayout` was defined in `core/wasm-codegen`, which is the wrong
crate for it once `pages` and `stack-size` become things a user writes.
`Inference.toml` must be validated when it is loaded, by `apps/infs`,
which cannot reach into code generation to learn what a legal layout is;
the alternative is a second copy of the invariants, and a second copy is
exactly the arrangement the previous commit removed.

`core/compiler-interface` already holds the user-facing vocabulary for
the same reason — `WasmFeatureName` and its diagnostics live there so
that a manifest entry and a command-line flag are rejected in the same
words — and both `apps/infs` and `core/cli` already depend on it. The
type moves there together with `PAGE_SIZE` and `FRAME_ALIGNMENT`, which
`validate` reads: the grid a rejected stack size is measured against
must be the grid code generation rounds frames to, or the error names a
constraint the emitter does not have.

Unlike `WasmFeatureName`, this type is not mirrored on the codegen side.
There, the user's request and code generation's emission permissions are
genuinely different things, and the mapping between them carries a
decision. Here they are the same two numbers, so a mirror would buy
nothing and give the invariants somewhere to drift apart.

`core/wasm-codegen` re-exports it, so `inference_wasm_codegen::MemoryLayout`
and `CodegenOptions.layout` are unchanged and all seventeen references
outside these two crates resolve unedited. No behavior change: same 51
targets, same 2906 tests, every golden byte-identical.
`b58075f` removed one hand-synced constant from this rule and left its
neighbour. `FRAME_ALIGNMENT` carried the same "mirroring
core/wasm-codegen" caveat and the same failure mode one step smaller:
A036 rounds every per-function frame estimate to this grid precisely so
the estimate cannot fall below the frame code generation really
allocates. A coarser grid on the codegen side would void that guarantee
silently, and the rule would then admit a call chain that overflows the
shadow stack — the direction that matters, since the rule exists to
reject exactly those.

That commit argued for adding no dependency, and this one adds one. What
changed is `afdcbe6`: the constant now lives in `core/compiler-interface`
beside `MemoryLayout`, a leaf crate whose purpose is to be depended on by
both sides of this kind of agreement. The coupling `b58075f` declined was
a dependency on code generation itself, taken to reach a constant private
to it; that is still declined here.

The relation the constant protects turns out not to rest on the two
numbers agreeing at all: `a036_estimate_is_sound_upper_bound_of_codegen_frame`
already asserts `estimate >= real` per function across a corpus, reading
codegen's own layout through `CodegenOutput::frame_sizes()`. A doc comment
in that file claimed the opposite — that per-function parity was validated
by construction because the field "is not exposed across crates" — which
has not been true since that accessor existed, and is corrected here.
An `[memory]` table in `Inference.toml` with `pages` and `stack-size`,
the `infc --memory-pages` / `--stack-size` flags it forwards, and ABI
minor 2 to 3 so `infs` can refuse rather than forward a request an older
compiler cannot honour. A project that declares no `[memory]` table
forwards nothing and still builds against a minor-2 `infc`.

The load-bearing part is not the flag; it is that one resolved layout
feeds two consumers. `--stack-size` sets the `__stack_pointer`
initializer *and* A036's budget from the same value. Wiring only code
generation is not a partial feature but an unsound one, and the
neutralization run shows both of its faces: with the analysis half
reverted, `infc --stack-size 8192` on a twelve-kilobyte call chain exits
0 and writes the module, and a single sixteen-kilobyte frame panics out
of frame layout with exit 101 instead of reporting anything. Wired, the
same program exits 1 with an A036 diagnostic naming the configured
stack. The test asserts that text and the absence of a panic, so a
regression cannot pass as a mere failure.

`MemoryLayout`'s fields are now private with `resolve` as the only
checked constructor, per the type-invariant rule in CONTRIBUTING. A
layout that cannot be emitted now has no representation outside
`core/compiler-interface`, which is why `codegen`'s validate-then-emit
refusal is deleted rather than moved: with the invariant held by the
type, that check guards nothing a caller could reach, and keeping it
would force `validate` to stay public purely to serve it — re-advertising
an obligation the encapsulation exists to retire.

Either key may be given alone, and the pair is checked jointly *after*
filling, which is what lets a value legal on its own still be refused:
`stack-size = 131072` with no `pages` is a 128 KiB stack in a 64 KiB
memory. Each layer's tests pin that rejection beside the `pages = 4`
case that makes the same stack size legal, so the diagnostic is about
the combination rather than the number.

Defaults are unchanged, so every emitted artifact is byte-identical;
all 212 golden `.wasm` files confirm it.
…#363, #420)

Tier B admits an external whose every memory access provably derives
from a parameter of the call. It does not show the access stays inside
the buffer that parameter points into, and it cannot: the analysis
carries no sizes. `p + q` and `2p` reach arbitrarily far with no
constant at all, and both are deliberate, test-pinned accepts.

A single fixed page was an accidental backstop for that gap. A reach
past the caller's buffer left the memory and trapped, so the missing
containment proof cost nothing observable. Configurable memory removes
the backstop: above one page the same reach lands in valid memory,
possibly a region another merged module owns. Nothing about the merge
became less sound, but a guarantee readers could previously rely on by
accident is gone, and that is worth saying out loud.

The condition is the *reconciled* memory size, not a raised `[memory]
pages`. The backstop is gone whichever module enlarged the memory — a
main configured to two pages and a memoryless main adopting a
seventeen-page external memory are equally exposed — and a
manifest-only check would stay silent on the second. The message names
the externals, states the derives-from/stays-within distinction plainly,
and says it reports the limit of what the merge proves rather than a
fault found in the merged code.

`link` keeps its signature, so the 109-site test suite and every other
caller are untouched; `link_with_warnings` carries `LinkOutput` and
`infc` prints each warning to stderr. The byte-identical fast path
returns `Vec::new()` for a stated reason: it runs only when there is no
external to warn about.

Two documentation corrections this branch owes. `4b00a7f` changed the
Tier-C gate but left two doc comments describing it as "own static data,
mutable globals" — wrong twice over, since globals and tables now gate on
use rather than mutation and tables and elements were missing entirely.
`974480f` made the linker README's claim that a pure leaf still
contributes its module's memory false, and left it standing.
The warning added in `2120f65` had five tests inside `core/wasm-linker`,
every one of them calling the library. The three lines in `infc` that
actually print it had none: deleting the loop left the whole suite
green, so nothing established that a user ever sees the message. That is
the same shape as a guard whose condition has been replaced by `true` —
covered everywhere except at the point where it does its work.

Two tests through the real binary. One build triggers the warning and
asserts it reaches stderr, the process still exits successfully, and the
`.wasm` is written — a driver that printed the message and then aborted
would satisfy a stderr-only assertion while breaking every such build.
The other builds the same project without `--memory-pages` and asserts
the message is absent, so a warning that fired unconditionally could not
pass. Both assert `Linked 1 external module(s)`, which is what makes the
negative case attributable: an external that silently failed to bind
would also produce no warning, for a reason having nothing to do with
the page count. The text is matched on the claim the warning exists to
make, not on its punctuation.

Two constraints shaped the fixture, and both are worth knowing before
writing another test in this area. A Tier-B external cannot be written
in Inference at all — a compound parameter is copied into the callee's
frame on entry, so no Inference function addresses memory through a
caller's pointer — which is why the external is assembled from WAT and
`wat` joins the dev-dependencies. And the program's array is load-bearing
rather than illustrative: code generation emits a memory section only for
a module that needs a frame, so without it the main module declares no
memory, the merge adopts the external's single page, and `--memory-pages`
has nothing to enlarge. The positive test would then pass while
exercising nothing.
Every optimizing toolchain emits `base + index * elem_size` or its shift
form, and the linker rejected all of it: the four scaling operators
produced `NotParam` unconditionally. Admitting them meant reasoning
about what arithmetic can do to a parameter, and that turned up two ways
a fixed absolute address was already passing as parameter-derived.

Neither needed the feature being added. `t = p; 32x (t = t + t)` leaves
`t == 2^32 * p == 0`, so a store at `t + 4096` writes address 4096 for
every caller — no call, no recursion, `i32.add` only. It was admitted,
and the module doc named `2p` as an accept *by design*, which is the
framing error underneath: doubling is not "reaching further from the
pointer", it is one step of walking the value to zero. The second is the
same trick across a call boundary — a root re-entered as `r(a, -a)`,
with `-a` built from additions.

The lattice now tracks affine forms by coefficient parity. A value is
parameter-derived when some coefficient is odd, because an odd
coefficient is a unit modulo 2^N: the value is a bijection in that
parameter and no further arithmetic flattens it to a constant. All-even
coefficients are `Scaled` — never an address alone, but `odd + even =
odd` is precisely the idiom this phase exists to admit. `Param + Param`
now requires disjoint odd supports, which is what kills both gadgets
while keeping the genuinely independent case.

The correlation rule applies to the odd support rather than the whole
mask. Scoping it to the full support would have rejected `base + (index
<< 2)` inside every called helper, which is where real toolchain output
puts the idiom — the phase would have bought nothing.

A third defect surfaced on the way: exceeding a loop's fixpoint round
cap was an over-acceptance, not a rejection. The analysis broke out and
kept the state from before the unfinished descent. It now yields no
verdict, and the cap is derived from the lattice height instead of
assumed, clamped separately by what termination cost allows.

Verified by execution, not only by argument: a differential harness runs
each fixture under wasmtime with disjoint arguments and requires an
accepted module's written byte-sets to be disjoint, and a declared
fabricator's to intersect — so a rejection fixture cannot quietly become
vacuous. It caught both gadgets when their guards were neutralized.
Reading or writing a module global was a Tier-C signal, so any closure
that touched one was refused. Merging them is mostly bookkeeping — an
output global list, a per-external remap, main's globals first — but it
sits on top of one operator-rewriting hazard that decides whether the
change is correct or silently wrong.

`global.get` and `global.set` fell into the rewriter's verbatim
catch-all. Merging globals without adding remapping arms in front of it
rebinds every external `global.get 0` onto the main module's global 0,
which is `__stack_pointer`. Neutralizing the arm shows exactly how quiet
that failure is: the link still succeeds, the output still validates,
and the global count, the section's presence and the operator's presence
are all unchanged. Only the operand tells the two apart, so that is what
the test asserts.

Globals are not deduplicated the way signatures are. Two externals whose
globals match in type and initializer hold distinct state, and
collapsing them aliases two modules' variables. An external contributes
its globals only when its closure uses one, which keeps an inert
declaration dropped and every pre-existing merge byte-identical.

The global remap is fallible, like the type remap rather than the
function remap. That is what makes the dropped space fail-safe: a
closure touching no global gets an empty remap, so a leaked `global.get`
fails its lookup instead of silently rebinding.

Two limits are recorded in `tier.rs` rather than smoothed over. A merged
mutable global's initializer is a claim on a region of linear memory
that no section the linker parses describes — which is the real reason a
later data-placement pass cannot prove disjointness on top of this, and
it holds whatever provenance tags a global read. And an external that
*returns* a global-derived value its caller then dereferences is Tier A,
never provenance-analyzed; that hole is not opened here, and the same
shape links identically with a bare address constant and no global at
all.
The linker envelope exists so real toolchain code can be reasoned
about, not merely folded in. This is the minimal end-to-end path: one
linked external, one main-side `spec` naming it, one obligation, proved.
Adopting an external's own shipped specs and write-set contracts stay
with #362.

A call to an `external fn` inside an assertion was `P005`. It now
resolves, keyed on the declaration's `DefId` rather than its name. The
distinction is not pedantic: `collect_top_level_extern_decls` never
descends into spec bodies, so a spec-inner `external fn` is never bound,
and a name-keyed lookup would hand it the top-level declaration's origin
and emit an obligation naming a merged body the call does not reach.
A024 rejects such a call first, which makes the keying defense in depth
everywhere except an analysis-skipping pipeline — and the proof-mode
gates are exactly that.

The obligation pass is therefore strictly more precise than code
generation, which resolves provenance by `DefId` but probes its import
table by bare name, program-wide. That divergence is real and
reproducible — the two disagree about which function a call names, and
the executable follows code generation — so it is recorded at the type
rather than asserted away. It is #423, and it is not this change's to
fix.

Also here: an arity backstop on symbol resolution, because a wrong-arity
application resolved, printed, and passed coqc — the gate cannot catch
it, the term being well-formed Rocq either way. And the coqc gate grew a
linked corpus, since it compiled one file and translated raw unlinked
wasm, so a two-file fixture could not be registered at all.

The obligation is discharged against the real verifier, not the vendored
stub: `ValidModule` and `ValidSpec` both `Qed`, no `Admitted`, no
`admit`, no `Abort`, and `Print Assumptions` within the verifier's own
allowlist. The stub is deliberately narrower than the backend, so
passing it proves the emitted shape is legal, never that the theorem
holds. A companion lemma proves the empty interpretation cannot
discharge the payload, so the `Qed` rests on the merged body being there
rather than on the application being silent.
A committed `cargo build --release --target wasm32-unknown-unknown`
artifact, its source, and the exact rustc version that produced it. CI
never needs the target installed, and anyone can regenerate and diff it.
The tests link it into an `infc` main, execute the result under
wasmtime, translate it, and elaborate the translation under coqc.

Before this branch that artifact was rejected three separate ways: the
width-changing operators were outside the envelope, lld's synthesized
`__stack_pointer` global forced Tier C on declaration alone, and its
sixteen-page memory failed reconciliation against a fixed one-page main.
Each of those is now a merge that produces a module which runs and
computes the right answers.

What the gate does **not** establish is that the fixture's obligations
are true. `admit_open_proofs` rewrites `Qed.` to `Admitted.`, so
elaboration proves well-typedness and nothing more; the executed points
are the only evidence either obligation holds anywhere. The fixture says
so rather than letting a green gate imply otherwise.

One honest gap, recorded rather than papered over: no real toolchain
body here witnesses the width conversions the envelope was opened for.
Written as a saturating add over an i64 intermediate, LLVM recognizes
the pattern at `-Oz` and lowers it to i32 compares and selects, so the
conversions never reach the artifact. They survive only at `-O0`, which
does not link for unrelated reasons. The operators are exercised by
hand-written fixtures and validated against the real verifier; what is
missing is a *toolchain-produced* witness.

The needle that checks the artifact still lowers as expected was itself
vacuous at first: matched module-wide, and the artifact carries two
`select`s, so the exact drift it documents would have stayed green on
the unrelated one. Needles are now scoped to the function that owns
them, proven by a stand-in with a branchy clamp and a select-keeping
`sum_n`.
The acceptance fixture did not exercise the operators this branch opened
with. Written as a saturating add over an i64 intermediate, LLVM
recognizes the pattern at `-Oz` and lowers it to i32 compares and two
selects — the artifact contains no `i64.extend_i32_s` and no
`i32.wrap_i64` at all. I had described that fixture as exercising them
because the Rust source computes through an i64, which is a claim about
the source rather than about the bytes, and it was wrong.

`mulhi` fixes it by making the wide intermediate load-bearing: the high
half of a full 64-bit product has no i32-only lowering, so the
conversions survive every optimization level that links. Confirmed by
decoding the body — two `i64.extend_i32_s`, `i64.mul`, `i64.shr_u`,
`i32.wrap_i64` — and again in the emitted Rocq, where they appear as the
`BI_cvtop` forms the translator is credited with and elaborate under
coqc.

The executed points include two negatives, and they are the interesting
ones: `mulhi(-1, -1) == 0` and `mulhi(-1, 1) == -1` pin the *signedness*
of the widening, which is what `(Some SX_S)` records. An unsigned
widening agrees on every non-negative input and returns -2 and 0 at
those two, so without them a wrong lowering passes silently.

Neutralized by rebuilding the crate with a plain 32-bit multiply and
swapping it in: the execution test fails at `mulhi(65536, 65536)` with
`left: 0` — the "stayed in 32 bits" outcome its message predicts — and
the Rocq needle fails naming the missing conversion. The whole coqc gate
stayed green, which is the expected and instructive part: elaboration
cannot tell the two apart.

Every number in the fixture README was re-derived from the binary rather
than patched, which caught a duplicated paragraph and a stale byte
count. The optimization sweep was re-run against the three-export crate
instead of assumed to carry: `-O0` still fails to link, every level that
links keeps both conversions.
Nine sites across eight files, doc-only. Every changed line is a
comment, and the suite is unmoved at 51 targets, 0 failures, 161
ignored.

Four of these were introduced by this branch and four of those by me.
Removing globals from the Tier-C gate left four docs still saying a
global forces Tier C — including the crate's front door and the doc on
the error variant itself, in two crates. That is not a wording slip:
`tier.rs` argues that merging globals is sound only because provenance
tags a global-derived address `NotParam`, and separately that merging
globals is mutually exclusive with placing external data segments at
their original addresses. A reader who believes global-touching closures
are already excluded reads both safeguards as redundant, and two unsound
changes then look like cleanups. The replacement is one paragraph,
byte-identical at all four sites, saying what does happen to globals
rather than merely dropping the false clause.

The scaled-index work left two more. An extent operand does not carry
the same requirement as an address — it is the same test minus the
correlation clause — and `sub` is not unconditional, having three
propagating arms, so it did not belong in a list asserting uniformity
with `xor` and `and`. Both contradicted a passage later in their own
module doc, which is the tell.

The type checker's phase lists said imports resolve before collection
when the code does the reverse. That one had concealed a live defect:
extern signature types are validated during collection, hence before
imports bind, so a qualified type on an `external fn` is rejected with a
diagnostic naming an import the file already has. Anyone reasoning about
ordering from those lists concludes the failure is impossible. Filed as
#425; the code fix and the missing ordering constraint belong there, and
the lists are corrected here because nothing in that fix would force
anyone to notice them.

Two claims are true today but rest on a fact nobody recorded: the fast
path's empty warning list is a fact about the path only while
`LinkWarning` has one variant concerning merged externals, and the two
`link_with_warnings` entry points return different verdicts on identical
bytes when there are no externals. Both are now stated where someone
about to break them will look.

Reviewed independently against the code rather than against the briefs
that produced it — which caught, among other things, that a corrected
paragraph had reintroduced the same concealment in milder form by
implying all signature validation follows import resolution.
@0xGeorgii 0xGeorgii self-assigned this Aug 18, 2026
@0xGeorgii 0xGeorgii added linker Linker memory management Related to how memory is tracked and manipulated labels Aug 18, 2026
@0xGeorgii
0xGeorgii merged commit d869f9e into main Aug 18, 2026
9 checks passed
@0xGeorgii
0xGeorgii deleted the 363-linker-envelope-numeric branch August 18, 2026 02:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

linker Linker memory management Related to how memory is tracked and manipulated

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Linker envelope: accept real-toolchain externals (own globals/data/tables; integer width conversions) for proof mode

1 participant