The WASM Linker
The language-level constructs — external fn, use … from, resolution priority —
are documented in
External Functions and WASM Linking.
This chapter is the subsystem deep-dive: what inference-wasm-linker actually does
to the bytes, why each decision was made, and where the approach diverges from
conventional linkers.
Motivation
Inference programs can call pre-compiled .wasm library functions, but verification
requires a single self-contained module. Imports are the enemy of that goal: a
module with a dangling (import "arith" "sum" …) cannot be fully verified, because
the Rocq translator has no body to reason about. Dynamic linking trades one problem
for another — it moves the unverifiable part from the import section to runtime.
The solution is a static whole-body merge: after codegen emits an import-bearing module, the linker copies the needed function bodies in, rewrites every index reference into a unified index space, and removes the import section entirely. The output has no dangling imports, no relocation step, and no runtime loader. Verification then covers the actual deployed artifact, not a stand-in.
Two kinds of import do survive, and they get there by skipping the link rather
than by passing through it. A use { f } from host::<module>; clause binds a
host import: a function the embedder supplies at instantiation, with no
.wasm in the build for the merge to fold in (see
Host Imports). A build
whose memory layout asks to import its memory ([memory] import = true) leaves
the other: the module's linear memory, imported as env.memory for the
embedder to supply (see Importing the
memory). For a
program that links no module and either binds only host imports or imports its
memory, inference::link_resolved never calls the merge. It ships the
code-generation bytes verbatim, after proving that the artifact's imports are
exactly the declared host functions and, when the layout asks to import its
memory, that env.memory import, by name and limits — see
The Host-Only Pass-Through. The merge itself is
unchanged, and every module it produces still has no import section. A program
that binds host imports and linked modules together is refused at external
resolution, before any .wasm is searched for; a merge that lets a host import
survive beside the modules it links is not built. A host import is also exactly
the dangling import this section calls unverifiable, which is why a program that
binds one is refused any build that writes a .v. A program that imports its
memory is refused every such build for the same reason, and is refused beside a
linked module too, by infc before external resolution, because the merge
models only a memory its modules define, not one a host supplies (tracked under
issue #546). inference::link_resolved refuses the combination itself as well
(ModulesWithImportedMemory), whatever the artifact imports, so a library
caller that hands it resolved modules and an expected memory import cannot link
a module that would leave the host's memory unread.
Where the Linker Fits in the Pipeline
Codegen produces an intermediate module whose external calls lower to
(import …) entries. The linker consumes that module plus the resolved .wasm
binaries and produces the self-contained module that flows into wasm-to-v:
.inf source
│
▼
parse ──▶ type-check ──▶ analyze
│
▼
codegen (wasm-codegen)
│
▼ main.wasm (import-bearing)
│ arith.wasm ──┐
│ sortlib.wasm ─┤
└─────────────────────────────────────────────┤
▼
inference-wasm-linker
│
unified.wasm (no imports)
│
┌─────────────┤
▼ ▼
.out wasm-to-v
(Rocq .v file)
The linker is provided by the inference-wasm-linker crate (core/wasm-linker/).
Its public API is a single function:
#![allow(unused)] fn main() { use inference_wasm_linker::{link, LinkError}; let unified: Vec<u8> = link( main_wasm, &[("arith", arith_wasm), ("sortlib", sortlib_wasm)], None, )?; }
The third argument, contracts: Option<&[ImportWriteSet]>, is the mode that
decides whether The Declared Write-Set Check
runs at all: None skips it (the mode for hand-written .wasm fixtures with
no Inference declaration behind them, such as the linker's own tests), while
Some(list) — the mode infc/infs uses — holds every satisfied import to a
declared write set built from the program's external fn declarations.
Each external is tagged with the logical module name codegen recorded for it. The merge resolves each import by matching both the logical module name and the export field name, so two libraries exporting the same field name under different logical modules are never conflated.
The Merge Algorithm
For each import in the main module:
- Find which external module exports a function of that name under the right logical module.
- Compute the transitive closure of that export inside its source module via breadth-first search — the functions it calls recursively, plus any unexported helpers.
- Classify the closure's feasibility tier (A, B, or C — see below).
- Dedup the closure's function types into the output type section (two functions with identical signatures share one type entry; the key is a byte-packed encoding of the parameter and result value types).
- Append the closure's bodies after the main module's local functions, rewriting every index-bearing instruction into the unified index space.
- Remove the satisfied import and redirect the main module's calls from the old import index onto the merged body's new index.
Index Space After Merging
The output defines one function index space, with no import section:
[0 .. main_local_count) main module's local functions (imports removed)
[main_local_count .. total) merged external functions, in closure order
Operator Re-encoding
The rewrite module walks each copied body's operator stream and re-encodes
only the index-bearing operators: call, return_call, ref.func,
call_indirect, return_call_indirect, and block/loop/if when carrying a
function type index. Every other operator is copied verbatim from the source
bytes. The main module's own bodies are re-encoded for the same reason: removing
imports shifts their local-function indices downward.
Dead-Code Exclusion
The transitive closure walk collects only the functions actually reachable from
each satisfied export. An unused function that the source module exports but
that no satisfied import calls is never pulled into the closure, so it does not
appear in the merged output.
The Shared Linear Memory
The output has one linear memory, shared by the main module and every merged
external whose closure touches memory, with a load, a store, memory.size or
memory.grow; an external whose closure touches no memory contributes nothing,
whatever its module declares. The merge records each satisfied import's memory
and computes the output memory once, from all of them together. Fixing it import
by import would make it depend on the order the main module lists its imports
in, which is the order the program wrote its external fn declarations in — and
swapping two declaration lines would then change the emitted memory, what
memory.grow returns, and the machine the .v describes.
If your program has its own memory, its maximum governs every linked module, and
the linker warns about each growing module whose own maximum it replaces.
Otherwise the linked memory is the tightest fit to the modules' own declarations,
which relaxes no module's bound, and a link that no memory fits, or that would
move where any module's memory.grow stops, is refused.
-
A main module with a memory keeps its maximum, whatever the externals' own caps, and widens its minimum to the largest any module declares. An external minimum above main's maximum is refused. A module that grows memory under a different maximum of its own is linked all the same, and the link raises one
LinkWarning::MemoryMaximumOverriddennaming each such import asmodule::fieldand saying whether the program's maximum raised or lowered the module's, so you can see wherememory.growthere now stops. A program built withinfc --max-memory-pages 10that linksb::growB, whose module declares(memory 1 2), prints:warning: the program's linear memory has a maximum of 10 pages, which governs `memory.grow` in every linked module and replaces the maximum a growing module declares where the two differ: raised for the module providing `b::growB`, which declares a maximum of 2 pages, so `memory.grow` there now stops at 10 pages instead of 2. Under a raised maximum, `memory.grow` can succeed where the module alone would return -1. The program decides the maximum by design, which is what lets libraries whose maximums differ link together. This is not a fault found in the program or the library; the program's maximum is set by `max-pages` in the `[memory]` table of `Inference.toml`, or by `infc --max-memory-pages <N>`.The warning is silent when the program's maximum equals the module's, and for a module that never grows memory, since only
memory.growobserves the maximum. A module that declares no maximum is named under any growable program, because itsmemory.growis now bounded. -
A memoryless main gets the largest declared minimum and the smallest declared maximum, unbounded only when no external declares one — the memory WebAssembly import matching would accept for every external's declaration. One external's memory is therefore adopted exactly as declared. With several, two outcomes are refused rather than settled by picking one module's bound over another's: declarations no memory satisfies, such as one module needing five pages beside one allowing at most two, and a module that grows memory under a cap other than the shared one. A module whose cap is lowered but that never grows memory is accepted: only
memory.growobserves the maximum, and a memoryless main exports no memory for a host to grow.
Either way, an external whose closure grows memory is refused when the resulting
memory is pinned (max == min), since every memory.grow would fail. That
includes a lone external adopted exactly as declared: one declaring five pages
that may grow to five, whose closure grows memory, is refused onto a memoryless
main rather than linked with a memory it can never grow.
Each refusal above names the remedy on the main module's side: pages (or
infc --memory-pages) for a minimum larger than main's memory can hold, and
max-pages (or infc --max-memory-pages) for the others. A [memory] table
makes the program declare a memory of its own even when its code touches none,
and that memory's maximum then decides. One page that may grow to one page is
the default layout, so max-pages = 1 alone does not. Contributions are counted
per satisfied import rather than per call, so an external fn the program
declares but never calls still brings its module's memory.
The Main Module's Data Segments
Module-scope compound constants (arrays and structs) live in linear memory above
the shadow stack, at [stack_size, stack_size + data_bytes), and the main module
initializes that region with one active data segment at an i32.const offset.
The merge carries the main module's segments into the output unchanged — same
order, same offsets, same bytes — in a Data section written right after the Code
section. A module with no segments gets no Data section, so a data-free program
links to exactly the bytes it did before segments were carried.
Three facts make "unchanged" correct rather than merely convenient. No data index
moves: only memory.init and data.drop name a segment, Inference codegen emits
neither, the linker refuses either in a main body, and an external closure using
either is Tier C, so no external segment ever joins the index space. No offset
moves: the main module's memory stays memory 0 of the output. And the segment
still fits: memory reconciliation only widens the minimum from the main module's
and keeps its maximum. The output never carries a DataCount section, which
SpaceWasm cannot decode; the validator requires one only of code that names a
segment, which the first fact rules out.
Any other main-side segment is refused with LinkError::UnsupportedConstruct
naming the segment and its problem: a passive segment, a segment over a memory
other than 0, or an offset expression other than a lone i32.const. These are
shapes Inference codegen does not emit; the refusals guard the public library
API. An external's data segments are a different matter and stay Tier C (see
What Each Tier May Touch).
Nothing in the program writes the constant region, and a linked external cannot
be handed it to write through a declared path: analysis rule A047 refuses a
compound argument at a mut external fn parameter unless it is rooted at a
mut binding, which a const is not, and the repair it suggests copies the
constant into a mut binding on the stack; a closure that stores through a
parameter not declared mut fails the
declared write-set check. That covers the
declared path and nothing more. Tier B proves derivation, not containment, so
an address the program passes as a scalar i32, or a displacement past a
granted buffer, can reach a constant exactly as it can reach a caller's frame —
the gap issue #420 tracks.
The Example from the Repository
The scratch/linker-e2e/ demo links three external modules simultaneously. The
Inference.toml declares them:
[wasm-dependencies]
arith = { path = "libs/arith.wasm" }
memlib = { path = "libs/memlib.wasm" }
sortlib = { path = "libs/sortlib.wasm" }
The source uses all three:
external fn sum(a: i32, b: i32) -> i32;
external fn neg(a: i32) -> i32;
use { sum, neg } from arith;
external fn store_at(mut ptr: i32, val: i32);
external fn load_at(ptr: i32) -> i32;
use { store_at, load_at } from memlib;
external fn sort_pair(mut ptr: i32);
use { sort_pair } from sortlib;
arith is pure arithmetic (Tier A). memlib and sortlib access memory only
through the caller-supplied pointer (Tier B). sort_pair transitively calls a
non-exported swap helper — the closure walk drags that helper in automatically,
and both functions merge. After linking, the output has no imports and one
reconciled linear memory shared by all three modules' bodies.
Feasibility Tiers
Not all external functions can be merged without relocation metadata. The linker classifies each closure:
| Tier | What the closure may touch | Merged? | Admission condition |
|---|---|---|---|
| A | No memory, no table access, no data segments — arithmetic, optionally over its own globals | Yes | None beyond the operator allow-list |
| B | Linear memory only through caller-supplied pointers; no own data segments, no table access; its own globals as scalar state, never as addresses | Yes | Provenance proof: every memory address is parameter-derived (see below); the closure's may-write set is covered by the external fn declaration's mut parameters (see The Declared Write-Set Check) |
| C | Own static data segments, or table/element use | No | Rejected with LinkError::RequiresRelocatableBuild |
What Each Tier May Touch
The classification logic inspects the parsed module structure and the closure's
ClosureEffects, collected as a side effect of the operator allow-list scan:
| Signal | Forces Tier C |
|---|---|
module.data_count > 0 or closure uses memory.init / data.drop | owns static data segments |
module.element_count > 0 or closure uses call_indirect / table.get / table.set / table.grow / table.size / table.fill / ref.func | uses a table or element segment |
The segment-indexed table forms (table.init, elem.drop, table.copy) never
reach classification: the operator allow-list rejects them as
LinkError::UnsupportedConstruct.
Reading or writing a module global is not a Tier-C signal: the closure's
globals are merged into the output alongside the main module's, with every
global.get / global.set remapped onto the merged index space. A global used
to address memory is still rejected, because the address it produces is not
parameter-derived. Writing one is no such hazard: a global.set between
computing an address and using it leaves that address's provenance intact.
If no Tier-C signals are present, the closure is Tier B when any body accesses linear memory (load, store, copy, fill, size, or grow), and Tier A otherwise.
Globals and table use are gated on use; data and element segments on
declaration. A global no body reads or writes, and a table with no element
segment that no instruction names, are inert — and they are exactly what real
toolchains emit unconditionally (lld puts a __stack_pointer global into every
wasm32-unknown-unknown artifact, and an empty (table 1 1 funcref) into every
std one), so rejecting on their declaration would exclude every such artifact.
The two declaration-gated signals rest on different arguments. An active data segment writes memory at instantiation whether or not any instruction names it, so an unreferenced one still changes program behavior — a correctness argument. An element segment is rejected as conservatism: dropping one is unobservable, since the merged output declares no table for it to initialize, but it marks a module built around indirect dispatch and admitting it would silently discard a construct the author wrote.
Dropping an admitted external's globals and tables is sound because
ClosureEffects is closure-scoped: a closure admitted with no global or table
effect contains no operator naming either index space. Neither half would fail
silently if that were ever violated. An external whose globals were dropped has
an empty global remap, so a leaked global.get finds no mapping and the link
fails with LinkError::UnsupportedConstruct, rather than rebinding onto main's
first global and, the types agreeing, passing post-merge validation with a wrong
value. A leaked table operator names a table the output does not have, since
no table section is emitted, and validation rejects it as unknown.
Clearing the tier gate is not the same as linking, and reconciling the memory is
one gate among several. A stock artifact also declares a multi-page memory, which
the merge adopts only for a closure that addresses memory: it widens the main
module's minimum to cover it but keeps the main module's maximum. A closure that
addresses memory, merged into a main whose memory is the default, a fixed
one-page (memory 1 1), therefore clears the tier gate and fails at memory
reconciliation with LinkError::IncompatibleMemory; a main whose [memory]
maximum reaches the artifact's minimum reconciles.
A Tier-A function carries no shared-memory surface at all: it reads its parameters, does arithmetic, and returns. Merge cost is a body copy, a type dedup, and an index rewrite.
A Tier-B function shares the output's single linear memory — the main module's own, or, for a main that declares none, one built from the linked modules' declarations (see The Shared Linear Memory). No address relocation is needed because every address is caller-supplied at runtime. However, the linker must prove this property — it cannot assume it from the section structure alone.
Tier-C Rejection
A Tier-C closure is rejected with LinkError::RequiresRelocatableBuild listing
the specific reasons:
error: external function `lookup` requires a relocatable build:
defines or initializes its own static data segments
The reasons field is a Vec<String> so a closure that both uses memory.init
and performs an indirect call reports both signals in one diagnostic.
Tier-B Provenance Analysis
The provenance analysis is the most novel part of the linker. Tier B's contract is that a merged external touches shared linear memory only through addresses the caller passes in. A function that fabricates an address from a constant or reads one from its own global would alias the host program's own linear memory at a fixed offset — a silent miscompile the section-inspection tier check cannot detect, because the function validates cleanly and its export signature matches.
The linker proves the contract with a sound, flow-sensitive, interprocedural abstract interpretation over the whole closure.
The Provenance Lattice
Every operand-stack slot and every local carries one of three provenance tags:
| Tag | Meaning |
|---|---|
Prov::Param(mask) | The value provably derives from one or more of this function's parameters, through operations that cannot cancel the caller's pointer. mask is a 64-bit bitset recording which parameters. |
Prov::Const | The value is a compile-time constant (*.const literal, or add/sub of two Consts). Caller-independent — never a valid memory address on its own, but a valid offset to add to a Param base. |
Prov::NotParam | Any other source: a global, a call result, a parameter-cancelling operator, or anything the analysis cannot prove parameter-derived. The fail-closed default. |
The lattice join is a must-join: a value stays Param only when it is
Param on every incoming control-flow path. The mask at a join point is the
union of the per-path masks (on every path it derives from some parameter,
so on the merged path it derives from one of the union). A value arriving as
Const on one path and Param on another widens to NotParam.
Why Const Is a Separate Tag
Const exists so a Param + Const expression (a struct-field or
array-element offset: base_ptr + 8) can stay Param while a Param + NotParam
expression cannot. A NotParam addend means not provably parameter-derived, not
constant. It may hold C - p (a constant minus a parameter), and
(C - p) + p == C is a caller-independent absolute address. Restricting the
non-Param addend to a proven Const closes that cancellation attack.
For the same reason sub propagates Param only from the minuend when the
subtrahend is Const (caller_base - fixed_offset is still caller-relative), and
Param - Param demotes to NotParam (since b - b == 0, a caller-independent
constant).
Every other binary operator — multiply, divide, bitwise, shift, rotate,
comparison — and every unary operator produces NotParam. The analysis is
deliberately conservative: it cannot distinguish param << 0 (value-preserving)
from param & 0 (value-destroying), so it treats all such operators uniformly.
Bulk-Memory Extent Operands
For memory.fill and memory.copy, the size/extent operand carries the same
caller-derivation requirement as the address operand. The operation touches the
contiguous region [address, address + size), so a caller-bounded start is not
enough — a constant or global size would let the operation clobber or read an
unbounded span above a caller pointer (memory.fill(base, v, 0x8000) scorches
host memory the caller never exposed). A Param size (a caller-supplied len)
is admitted; a Const or NotParam size fails the subset check.
Interprocedural Fixpoint
Each function in the closure is summarised once by seeding parameter i with
Prov::Param({i}). The summary records, per function, the provenance mask of
every memory access, and per call site, the argument mask of every argument in
the calling function's own parameter terms.
A greatest-fixpoint pass over the call graph then computes, for every function
g, the set trusted[g] — the subset of g's parameters that are provably
caller-derived:
- The closure root's parameters are all trusted (the host that calls the exported function supplies them).
- A parameter
jof a non-root functiongis trusted if and only if, at every internal call sitef → g, the argument in positionjhas a non-empty mask that is a subset oftrusted[f].
The iteration starts from "all parameters trusted" and removes any parameter
contradicted at a reachable call site, converging in at most (slot count + 1)
rounds. The fixpoint handles self- and mutual recursion correctly. A function
reachable only through the table (no direct call site) starts with the empty
trusted set — a dereference of its parameter is rejected.
Finally, every recorded memory access is verified: its address mask must be non-empty and a subset of its function's trusted set. A failure at any point rejects the whole closure as Tier C, never Tier B.
root_params = {ptr, len} (trusted by the external caller)
inner(addr, count):
addr derived from ptr → trusted
count derived from len → trusted
memory.fill(addr, 0, count) → both masks ⊆ trusted → Tier B ✓
inner2(addr, count):
count is i32.const 0x8000 → Const, mask = {} → mask ⊄ trusted → Tier C ✗
The Declared Write-Set Check
Tier-B admission proves that every memory address a closure computes derives from a caller-supplied parameter. It does not say which parameter licenses a given store, and it does not have to — until the closure is also held to a declared write set.
mut on an external fn parameter is the Inference-side declaration of that
licence: it states that the foreign body may store through the address that
parameter denotes. The linker checks the claim, keyed on (module, field) —
the same pair an import is satisfied on — against the merged bytes, in one of
two ways depending on what the declaration says:
- No parameter marked
mut(an empty declared set) is checked structurally: the merged closure must record noStoreaccess anywhere, not merely an attributed write set that happens to come out empty. A caller relying on "this closure writes nothing, anywhere" then inherits none of the attribution pass's own assumptions. - Some parameter marked
mutis checked against a second, forward least-fixpoint attribution pass — distinct from the greatest-fixpointtrusted[g]computation above — that computes, for every functiongin the closure, the set of the root export's parameters each ofg's own parameters may derive from, seeded at the root and propagated through every call site's argument dependencies. EveryStoreaccess's address is then attributed to the union of root parameters its dependencies may derive from. The attributed set must be a subset of the declared one, or the link fails withLinkError::UndeclaredExternWrite, naming the offending parameter by index and, where the declaration gave it a name, by name.
An import the checked mode's declaration list does not mention is treated as
declaring no mut parameter, never skipped — see
Where the Linker Fits in the Pipeline
for the two contracts modes that decide whether the check runs at all, and
for which callers use each one.
This check licenses derivation-to-a-parameter, not containment within it —
the same limit Tier-B Provenance Analysis
describes. W ⊆ D bounds which parameter a store's address derives from; it
says nothing about which bytes the store touches, or whether they stay
inside the region the caller meant that parameter to grant. A declaration
external fn writer(mut a: [i32; 2], b: [i32; 2]); whose body stores at a + 8 — past a's own two elements — links cleanly, because the address still
derives from parameter 0. Closing that gap needs the numeric/interval domain
issue #420 tracks; the write-set check narrows which parameter a store may
be attributed to, not where within it the store lands.
That narrower guarantee is nonetheless enough to license one thing safely:
core/wasm-codegen's by-reference optimization elides a compound parameter's
entry copy only when every external it reaches declares no mut
parameter at all. An empty declared set forces the structural "no Store
access anywhere" fact above, and a closure that never stores cannot disturb a
caller's elided copy — no containment property is needed for that narrower
claim. A mixed declaration (writer(mut a, b)) still costs every parameter
it reaches a copy, including b's: per-position elision is deliberately not
implemented, because it would need the containment property this check does
not have. See core/wasm-codegen/docs/arrays-and-memory.md for the codegen
side of this decision.
The attributed set can also be broader than "this parameter is itself a
written-through address" suggests, for a reason distinct from the
containment gap above: the attribution is affine, and it counts every
parameter that may contribute to a store's address, not only the one
playing the role of base pointer. The ordinary scaled-index write mem[ptr + (idx << 2)] = val attributes to both ptr and idx — idx never denotes
an address on its own, but it scales one, and the affine form the
attribution tracks does not distinguish that role from a base pointer's. A
declaration external fn set_elem(ptr: i32, idx: i32, val: i32); bound to a
body that writes this way must therefore mark idx, not only ptr, mut —
set_elem(mut ptr: i32, mut idx: i32, val: i32); — or the link fails naming
parameter 1, even though nothing about idx's own value is a location the
body writes to. This runs in the same conservative direction as the
containment gap: attributing too broadly can force a wider declaration than
the informal reading of mut suggests, never let an actual write through
undeclared.
The read/write split above is what keeps this from over-reaching further.
memory.copy(dest, src, size)'s src operand is recorded as a Load, never
a Store, so it never contributes to any Store access's dependency and is
never pulled into the attributed write set — even though the same
instruction also writes dest. A pure source pointer stays correctly
unforced; it is specifically a store's own address computation whose
affine contributors all get pulled in, idx-style scaling parameters
included.
Proof-Only Stripping
Inference non-deterministic blocks (forall, exists, assume, unique) and
uzumaki rvalues (i32.uzumaki, i64.uzumaki) are proof-only constructs:
they have meaning solely in the Rocq lowering and no executable runtime semantics.
A function that is merged into the output is part of an executable binary, so a
proof-only opcode inside such a body would yield a non-executable output.
The operator allow-list (src/safety.rs) rejects every proof-only opcode from
an external body with LinkError::UnsupportedConstruct:
unsupported WASM construct for static merge: non-deterministic block `forall`
has no executable semantics and cannot be merged into an executable binary
The main module's own proof scaffolding (its spec blocks and non-det opcodes)
is preserved and passed through verbatim — the re-encoder recognises those
opcodes and copies them intact onto the main-module bodies, which are not subject
to the allow-list. Those bodies flow through wasm-to-v as the proof obligations
the user wrote.
Floating-Point Exclusion
The Inference language has no f32/f64 types. The Rocq translator models no
float instruction. The linker enforces this at two gates:
-
Feature gate (
SUPPORTED_WASM_FEATURESinsrc/lib.rs): every external module is structurally validated before any body is touched. The feature set deliberately omitsFLOATS, so a float-using external is rejected upfront withLinkError::UnsupportedWasmFeaturenaming the exact feature. -
Operator allow-list (
src/safety.rs): the main-module re-encode path does not pass through the feature gate, so the allow-list is the backstop. Every float instruction — comparisons, arithmetic, conversions, reinterprets, loads/stores, and constants — is rejected withLinkError::UnsupportedConstructnaming the exact mnemonic (e.g.floating-point instruction 'f32.add' is not supported).
Saturating float-to-int (i32.trunc_sat_f32_s, etc.) is also excluded: its
operands are floats, and the Rocq translator declares no float number type.
Sign-extension (i32.extend8_s, i64.extend32_s, etc.) is not excluded,
though Inference codegen still emits none of it. The Rocq translator lowers all
five opcodes to BI_unop t (Unop_extend n) — the proof model classifies
sign-extension as a unop, beside clz/ctz/popcnt, not as a conversion — so
an external compiled by a real toolchain can carry them.
Name Preservation
The linker preserves the WASM name custom section so the Rocq translator emits
named Definitions rather than opaque func_<idx> placeholders:
- Main module local functions keep their source debug names, re-indexed onto the import-free output space.
- Every merged external function is named under its source's logical module,
joined with
:::- A closure root satisfying import
sumbound under logical modulemathlibbecomesmathlib::sum. - An internal callee the source module named keeps that name behind an internal
mark:
mathlib::#helper. - A nameless inner callee (an external with no name section) receives a
deterministic fallback derived from its output index:
mathlib::#func_<idx>.
- A closure root satisfying import
The section is one namespace with two halves, and :: is the boundary. A
compiled function's name-section symbol is built from Inference identifiers
joined by ., so it can never carry a :: a source module named mathlib and a
linked logical module named mathlib are free to coexist. Within the merged
half, the module prefix keeps two libraries that export the same field apart, and
the internal mark keeps a module's private callees from shadowing its own roots —
an inner debug name comes from the foreign module and is unconstrained, so it may
be exactly an export field.
A WASM name map holds one name per function index. When one foreign body satisfies two imports (an export bound under two fields), the least of its root names is recorded and an obligation over the other is rewritten onto it, so both declarations still describe the body they name.
The Rocq translator (core/wasm-to-v/src/rocq_names.rs) maps every byte outside
[A-Za-z0-9_] to _ and collapses the runs, so mathlib::sum becomes
Definition mathlib_sum in the .v file — the same identifier the dotted form
produced. A residual collision after sanitization (two modules that sanitize to
one identifier) is still disambiguated by the translator's index suffix; the
scheme removes the common cases rather than every possible one.
The Host-Only Pass-Through
A program whose externs are all host imports has nothing to merge: there is no
.wasm to fold in, and its imports are meant to survive for an embedder to
satisfy. So does a program that links no module and imports its memory: the
memory is meant to survive for the embedder to supply.
inference::link_resolved — the one place the choice between the two paths is
made, and the path infc ships through — therefore returns the code-generation
bytes unchanged whenever no module was resolved and there is something to keep,
a host import, an imported memory or both. The memory is not part of
resolution's answer: it is the memory: Option<MemoryImport> argument, which
infc fills from the build's layout and which is None for a build that
defines its memory. The language surface that produces a host import is
described under
Host Imports.
What stands in for the merge's guarantee is one check: the artifact's import
section must equal the declared host set, compared on module, field, parameters
and results, in both directions, and must carry the memory import the layout
asked for and no other. An import no declaration covers (Undeclared), a
declaration no import covers (Missing), a signature that differs
(SignatureMismatch), and one two-level name imported twice (Duplicated) are
each refused, as is any import of a table, a global or a tag, or of a memory
when the layout asked for none (NonFunctionImport). When the layout does ask
for one, the memory import is accepted once, and only if it is named
env.memory and has the type the layout declares: 32-bit, unshared, at the
default page size, with pages as its minimum and max-pages as its maximum,
which is always present. A memory under another name or at another type —
other limits, no maximum, shared, 64-bit or a custom page size — is a
MemoryImportMismatch, whether it stands in place of the expected import or
beside it; a second memory under the memory's name, or a function imported
under that name beside the memory, is Duplicated; and an artifact that
imports no memory when the layout asked for one is a MissingMemoryImport.
Bytes that do not decode are a refusal too, never an empty import
list: an empty list compares equal to an empty declared set, so bytes that do
not parse would otherwise pass as a module that imports nothing. The declared
set comes from the external-module driver's walk over the type checker's extern
provenance, and the import section from code generation's own walk over the
same declarations. A disagreement between the two is the compiler contradicting
itself rather than anything an author wrote, so each of these refusals
(inference::wasm_link::HostImportError, reported by infc as Link step failed: …) ends by asking for a compiler-bug report. The memory import is held
to the layout instead, the value code generation wrote it from, and a
disagreement there is the same contradiction.
The memory is held to the layout, and not merely to being a memory, because the program was laid out in it: the stack and the static data sit within the minimum, and the headroom the stack-overflow trap relies on is measured against the maximum (see Configuring the layout). An import that drifted from the layout would hand the embedder a contract the program was not laid out for.
What is not checked is everything the merge's main-side gate exists for. That
gate lets the merge assume a module it can rewrite — index spaces it can
renumber, sections it can concatenate — and no rewrite happens here. So the
pass-through accepts what the linker would refuse in a main module: a data
segment of a shape the merge does not carry (passive, over another memory, or
at a computed offset), an element segment, a start function, a table, a memory
of the module's own (a second one, or one beside the imported memory), a float
or v128 in one of the module's own signatures, and a duplicated or malformed
inference.spec_funcs or inference.hspecs custom section. No body is
re-encoded through the operator allow-list, no
tier is classified, and no write set is derived, since there is no foreign body
to derive one from; that is why mut on a host parameter is an assertion
rather than a checked contract (see
mut on a host parameter).
The shapes that pass are ones this compiler's code generation does not emit;
what cannot be taken on trust about bytes nobody rewrote is that their surviving
imports are the ones the build asked for, the author's host bindings and the
memory its configuration imports, and that is what the check establishes.
Nor does validate_extern run: there is no library to hold a declared signature
against, and the consistency check among the declarations at resolution stands
in for it (see
Declaring an External Function).
Error Reference
| Error | Trigger |
|---|---|
LinkError::Parse(msg) | A module's bytes could not be parsed as valid WASM, or a module that passed structural validation contains a malformed section (over-declared locals count, invalid LEB128, etc.). Under adoption it also covers a malformed or duplicated inference.spec_funcs/inference.hspecs section in a linked library, with the logical module named; under the other two settings those bytes are not read at all |
LinkError::UnsatisfiedImport { field } | No external module tagged with the right logical module name exports a function named field |
LinkError::TransitiveHostImport { module, field } | A body inside the merged closure calls one of the external module's own imports; there is no body to copy for it |
LinkError::RequiresRelocatableBuild { field, reasons } | The closure for field is Tier C; reasons lists each signal (e.g. "defines or initializes its own static data segments") |
LinkError::UnsupportedConstruct(msg) | A body contains an unmergeable construct: any floating-point instruction (with the exact mnemonic), a proof-only non-det or uzumaki opcode in an external body, a tail call (return_call / return_call_indirect), a segment-indexed table op (table.init / elem.drop / table.copy), a float or v128 value type in a merged signature or local, multi-memory access, or a main module section the merge cannot preserve (start function, table section, non-function imports — an imported memory among them, though infc refuses a linked binding beside one before the merge is reached, tracked under issue #546 — element segments, and data segments other than active ones over memory 0 at a lone i32.const offset — see The Main Module's Data Segments), or a main body naming a data segment through memory.init / data.drop |
LinkError::UnsupportedWasmFeature { module, details } | The external module is well-formed WASM but uses a feature beyond the supported subset (floats, saturating float-to-int, reference types, SIMD, atomics, exceptions, memory64, multi-memory, multi-value, GC, or tail calls); details carries the validator's feature-named diagnostic |
LinkError::AmbiguousImport { module, field } | More than one supplied external exports a function of the same field name the import requests under the same logical module; the body to merge is ambiguous |
LinkError::IncompatibleMemory { field, reason } | The linear memory requirements of the main module and the Tier-B externals cannot be reconciled into one shared output memory: an unsupported memory shape, an external minimum above the main module's maximum, declarations no one memory satisfies onto a memoryless main, a growing external whose cap the shared memory would move, or growth against a pinned result (see The Shared Linear Memory) |
LinkError::InvalidMergedModule(msg) | The post-merge structural validator rejected the merged output; this is a guard against allow-list gaps — it converts a potential silent miscompile into a clean diagnostic |
LinkError::UndeclaredExternWrite { module, field, param_index, param_name } | A Tier-B closure's attributed write set is not covered by its external fn declaration's mut parameters (checked mode only — see The Declared Write-Set Check); the message names the offending parameter and, when the declaration uses an unnamed form, says to name it before it can be marked mut |
LinkError::UndescribedExternWrite { module, field, param_index } | A Tier-B closure may store through a parameter, and the checked-mode contract list supplied for this link holds no entry at all for this import — named or unnamed. An import nothing describes is held to writing nothing, so the store is refused rather than admitted unchecked |
LinkError::DuplicateWriteContract { module, field } | The checked-mode contract list holds more than one entry for the same (module, field) import; the linker has no basis to choose which one governs |
LinkError::UnresolvedObligationSymbol { symbol, merged_roots } | A function symbol the main module's inference.hspecs obligations apply is carried by no function of the merged output. merged_roots lists every <module>::<field> the merge did satisfy, so the message can say what was on offer. |
LinkError::AmbiguousObligationSymbol { symbol, carriers } | Two or more functions of the merged output carry one applied obligation symbol. carriers says where each came from — the program's own code, a satisfied import, or a linked module's private function. |
LinkError::CheckedGuardUnderReachabilitySpec { spec_function, guarded_function, external } | A retained exists/unique specification function reaches, through calls, a function of the merged output whose body traps on arithmetic overflow. The obligation is a claim about running that body, so a trap anywhere in the activation makes it false rather than narrower. external names the logical module a library supplied the body under, and is absent when the program supplied it. Since +, -, * and unary - trap unless written wrapping(...), this is what a reachability specification over ordinary arithmetic code meets |
LinkError::UnresolvedReachabilitySpecFunction { spec, symbol, reason } | An exists/unique obligation of the main module names a specification function the merged module does not answer for. That function is where the reachability walk starts, so an unresolved symbol is not a check that passed but one that never ran |
LinkError::AdoptedSpecUnlisted { module, spec } | Adoption only: the library ships obligations under a specification its own inference.spec_funcs section does not list, so its two verification sections disagree with each other |
LinkError::AdoptedSpecNameInvalid { module, spec, key, reason } | Adoption only: the name an adopted specification would take is not one the proof translation can spell as an identifier; reason is the structural clause that rules it out |
LinkError::AdoptedSpecNameCollision { spec, module, contender } | Adoption only: the name an adopted specification would take is already claimed — by a specification the program declares (contender: None) or by another library's adopted specification |
LinkError::AdoptedObligationSymbolUnresolved { module, spec, symbol } | Adoption only: an adopted obligation applies a function symbol no function of its own library's name section carries |
LinkError::AdoptedObligationSymbolAmbiguous { module, spec, symbol, carriers } | Adoption only: several functions of the library carry the symbol an adopted obligation applies; carriers lists their indices in the library |
LinkError::AdoptedObligationUnmergedSymbol { module, spec, symbol, imported } | Adoption only: an adopted obligation applies a function of its library this merge did not fold in — one outside the export closure (imported: false) or one of the library's own imports (imported: true) |
LinkError::AdoptedSpecSymbolCollision { module, spec, symbol } | Adoption only: the merged-namespace symbol an adopted specification function would take is one a function of the merged output already carries |
Supported WASM Subset
The linker accepts only the following feature set (SUPPORTED_WASM_FEATURES
in src/lib.rs):
- Integer core:
i32/i64value types, all integer arithmetic, comparisons, loads/stores, and the three integer width conversions (i32.wrap_i64,i64.extend_i32_s/u). - Mutable globals, bulk memory (
memory.copy/memory.fill), and sign-extension (i32.extend8_s,i32.extend16_s,i64.extend8_s,i64.extend16_s,i64.extend32_s).
Everything else is rejected at the feature gate or the operator allow-list before any body is copied.
Formal Implications
Once merged, every external function becomes an ordinary local function in the
output module. The Rocq translator processes that module without knowing which
functions originated externally: each merged body becomes a Rocq Definition in
the .v file, exactly as a locally-defined function does.
Verification therefore covers the actual deployed merged artifact, not a
stand-in. A proof that sort_demo returns the expected value reasons about
the real mathlib_sort and memlib_store_at bodies — not their declared
signatures.
Two custom sections carry the program's proof obligations, and the linker treats them differently because their payloads are shaped differently:
inference.spec_funcs(src/spec_funcs.rs) records, per spec, the WASM function indices that make up that spec — its membership list, not its obligations. The Rocq translator reads it to decide which functions leave the emitted module record (aforallor plain spec function becomes a downstream contract, so its body is dropped) and which stay in it (anexistsoruniquefunction's body is what its reachability judgment reduces), and to disambiguate which function a reachability obligation judges. The merge removes imports and shifts every function index, so an index recorded pre-link is stale afterward; the linker decodes the section, remaps each index through the unified index space viaPlan::map_main_func, and re-emits it canonically.inference.hspecsrecords thehassertobligations anassert(...)inside a spec compiles to, referencing the functions they apply by symbolic name — aname-section string, not an index — so no index remap applies. The merge re-emits it otherwise unchanged, editing exactly one thing: when a merged body ends up recorded in the output under a different alias than the one an obligation names (the aliased-export case in Name Preservation above), the affected symbol is rewritten onto the alias the output's name section actually carries. A merge whose obligations still fail to resolve after that rewrite is rejected — seeLinkError::UnresolvedObligationSymbolandLinkError::AmbiguousObligationSymbolin the Error Reference below.
A third inference.* section crosses the link and carries no obligation at all.
inference.checked (src/checked.rs) lists, by function index, the bodies
of its module that trap on arithmetic overflow. Since +, -, * and unary
- trap unless the source wrote wrapping(...) around them, most modules with
arithmetic in them carry it. Its absence is the producer's statement that
none of its bodies carries such a guard, which is exactly true of a module any
other toolchain produced. This is the one section the linker reads rather than
merely carries: it remaps and re-emits the indices like inference.spec_funcs,
and it walks the merged call graph from each retained exists/unique
specification function to refuse a link in which one reaches a guarded body. A
second wire form carries no indices and says only that some body of the module
traps — what infs writes over an artifact a post-build optimizer has just
renumbered — and the linker reads that as every function of that module.
A fourth, inference.bounds_elided (src/bounds_elided.rs), shares that wire
format and is only carried. It lists the bodies of its module that hold an
array access emitted without its runtime bounds guard, which only a build under
the omit-proven bounds-check policy writes. The linker decodes it under every
role, rewrites it through the same index mappings, and re-emits one merged list,
so a program that keeps every guard and links a library that did not still says
so in the module it ships; a library body no closure pulled in drops out of it,
and an opaque input makes the merged list opaque. Both sections are decoded by
one codec, src/func_list.rs.
Both obligation sections above describe the main module's own sections. An
external module's inference.spec_funcs and inference.hspecs — the spec
membership and hassert obligations a linked library recorded about its own
code — describe a module the output is not: only the executable closure of a
satisfied export crosses the merge, and a library's specification functions are
never in it. What the link does with them is an explicit input,
LinkOptions::external_specs, with three settings:
Warn(the default, and what a build that writes a.vuses). Neither section is decoded — a malformed one in a library nothing needed still cannot fail a link — and aLinkWarningnames each library whoseinference.hspecsthe output does not carry, together with both spellings of the opt-in. The emitted bytes are exactly what they were.Ignore. The same bytes, and nothing said. A build that writes no.vuses it: no proof artifact exists for the obligations to reach, and the report would fire on every compile of every program that links a proof-mode library.Adopt(infc --adopt-external-specs, oradopt-external-specs = trueunder the manifest's[verification]table). Each contributing library's verification sections are decoded and its universal (forall) obligations are written into the merged module's own sections under<logical module>_<library's spec name>—mathlibplusDoubleSpecgivesmathlib_DoubleSpec, anda::bgivesa_b_DoubleSpec. Every function symbol such an obligation applies is resolved in the library's ownnamesection, required to be one of the bodies this merge actually folded in, and rewritten to the exact string the output'snamesection carries for that body — so an adopted obligation names the body its author wrote it about, and never a same-named function of the program. The adopted spec has no specification function of its own in the output, so itsinference.spec_funcsentry lists no index, which is what a universal obligation needs:ValidSpecis discharged denotationally and never reduces a spec body.
exists and unique obligations are not adopted under any setting, and
adoption reports each one it left behind. Their judgments are evaluated against
the frame an execution of the specification function reaches, and that function
is precisely what does not cross the merge. A library whose obligations are all
reachability obligations contributes no spec at all rather than an empty one: a
ValidSpec over an empty list is true of every module and would state nothing
about the program.
Adoption fails closed. A spec name colliding with one the program declares or
with another library's, a name the proof translation cannot spell as an
identifier, a symbol the library's own name section carries on no function or
on several, an obligation over a function this merge did not need, and a
library whose two verification sections disagree with each other are each a
hard LinkError naming the library, the specification and the symbol — see the
Error Reference.
Adoption carries obligations, not proofs: each arrives as a ValidSpec theorem
with an unfilled proof, to be discharged against the merged module. That is
what makes it sound across everything the merge changes about the library's
environment — one shared linear memory, remapped globals, renumbered calls.
Comparison with Traditional Linkers
Traditional wasm-ld (LLVM's WebAssembly linker) supports relocatable object
files: each compiled translation unit emits relocation metadata (symbol tables,
reloc sections), and wasm-ld patches absolute addresses and index references at
link time. That model handles Tier-C inputs (static data, indirect-call tables),
and globals that hold addresses such as a shadow-stack pointer, without a
provenance proof, because the relocation metadata describes exactly what needs
patching.
Inference cannot use wasm-ld for two reasons:
-
Verification needs a relocation-free module.
wasm-ldproduces a module that is self-contained at runtime but which was assembled from relocatable pieces. The Rocq translator expects to reason about the final module; it has no model for relocation metadata or the toolchain decisions it encodes. -
External libraries are not necessarily compiled with Inference. They may come from any WASM toolchain and need not carry
wasm-ld-compatible relocation sections. The static merge works on any conforming WASM binary — no toolchain cooperation is required for Tier-A and Tier-B inputs.
The provenance analysis fills the gap: instead of relocation metadata, the linker proves that the merged function cannot produce an address the host program did not supply. A function passing that proof needs no relocation, because its memory accesses are bounded to whatever region the caller chose to expose.
| Concern | wasm-ld | Inference static merge |
|---|---|---|
| Tier-A/B inputs | Requires reloc sections | Any conforming WASM binary |
| Tier-C inputs | Supported via relocation | Rejected (RequiresRelocatableBuild) |
| Address safety | Relocation metadata | Interprocedural provenance proof |
| Verification | Reloc artifact not translatable | Merged module translates directly |
| Runtime loader | Not required (static) | Not required (static) |
Tier-C support via relocation metadata is a stated future direction. The current linker explicitly gates on Tier A and B rather than risk a silent miscompile from an unproven address.
Related Resources
- External Functions and WASM Linking —
the language-level feature:
external fn,use … from, resolution priority, and the Tier A/B/C overview - Projects and the infs Toolchain —
configuring
[wasm-dependencies]and building with the project-aware CLI - Compilation Targets — compile vs. proof modes;
the merged module flows through the same
-vproof path as a locally-compiled module core/wasm-linker/README.md— merge algorithm, tier classification, index space, name section, testing, and fuzzingcore/wasm-linker/src/provenance.rs— full source of the abstract interpreter (module-level doc comment is a complete specification)- WebAssembly binary format — section ordering, index spaces, and the name custom section