Linking and installingalpha
Why is the artifact unlinked, and what do I do with it?
The wasm the compiler canister produces is an unlinked module: the Motoko runtime is not in it, its RTS imports are unresolved, and link-only exports are still present. It is not a deployable canister and it is not supposed to be. Linking is a second, separate step, and moxzi build --remote performs it for you locally before writing the file.
The artifact fails validation, and that is correct#
If you fetch bytes straight out of getArtifact, getArtifactChunk or jobWasmChunk and run a wasm validator over them, validation fails. This is the single most common confusion about on-chain builds, so it is worth being blunt:
| observation | verdict |
|---|---|
wasm-validate rejects the artifact | expected — it is unlinked |
the module has a duplicate keep_memory_reserve export | expected — link-only exports are pruned by the linker |
| the module imports symbols nothing provides | expected — the RTS supplies them |
dfx canister install rejects it | expected — link it first |
getCandid(id) returns service : { } | expected on the EOP backend — see below |
None of these are bugs. The compiler emits -no-link output by design, exactly as moc -c does, and the linker is a different program.
What the linker actually does#
The linker embeds the enhanced-orthogonal-persistence RTS, resolves relocations, prunes the link-only exports, and re-attaches the custom sections that carry the candid interface and the persistence marker.
That last clause is load-bearing. The wasm AST the linker decodes into carries no custom sections, so a naive decode-link-encode drops all of them. When that happened, it silently removed icp:private enhanced-orthogonal-persistence and the IC then refused every upgrade:
Invalid upgrade option: The `wasm_memory_persistence: opt Keep` upgrade option
requires that the new canister module supports enhanced orthogonal persistence.
About 50 stabilization and upgrade tests failed and it looked exactly like a compiler regression. The linker now copies the input's custom sections through verbatim — except name, whose function indices are stale once the RTS adds functions, which is what moc's linker does too.
The linker is itself a Motoko canister, and it is the same one the on-chain pipeline runs. moxzi hosts that wasm in-process under wasmtime rather than reimplementing the linker in Rust, so a locally linked artifact and an on-chain linked one come out of identical code.
Linking by hand#
moxzi link hello-unlinked.wasm -o hello.wasm
Default output is <input>.linked.wasm. moxzi link writes no .did.
The linker wasm is found in this order: --linker <path>, then $MOXZI_LINKER, then <exe dir>/linker.wasm, then <exe dir>/../lib/moxzi/linker.wasm, then bin/linker-canister/linker.wasm. If none exist, build it:
make moxzi-linker # -> bin/linker-canister/linker.wasm
Under 2 MiB the CLI calls the linker's one-shot link(blob). Above that it uses clearChunks → addChunk (1 MiB at a time) → linkChunks → getOut(offset, len), because a multi-megabyte module exceeds the ingress caps when the linker is a real canister. The same code path is used locally so there is only one path to keep honest.
Linking on-chain instead#
Deploy the linker as its own canister and drive the chunked API against it — that is what test/pic-js/scripts/link-only.mjs does, and link-install-prove.mjs goes on to install the result and prove it answers. The linker canister needs a large wasmMemoryLimit (the pic-js drivers give it 12 GiB; the deployment runbook says over 3 GiB for large modules).
This matters for the provenance argument. moxzi build --remote links locally, and that is a deliberate limitation rather than an oversight: linking is a mechanical, deterministic step over the compiler's output, so it does not weaken the claim the way a local compile would. Anyone who wants the whole pipeline attested will want the link moved on-chain too, and that is separate work.
The .did gotcha#
On the EOP backend the compiler sets the job's candid text to the placeholder service : { } — the interface file is a separate artifact that does not affect the wasm bytes. The real interface lives in the linked module's candid:service custom section.
| build | where the .did comes from |
|---|---|
local moxzi build | the linked wasm's candid:service section, falling back to getCandid |
moxzi build --remote | getCandid only |
So a remote build can write a .did containing service : { }. If that happens, the interface is still in the linked wasm; extract it from the candid:service custom section rather than trusting the written file. To get the section emitted as public metadata at all, the build must pass publicMetadata — dfx-shaped frontends pass ["candid:service", "candid:args"], and that is what make compiler-canister-wasm does for the compiler itself.
Installing#
Once linked, the artifact is an ordinary canister module: dfx canister install, ic-admin, an agent, or the management canister directly. Nothing about it is special.
The compiler canister also offers installFresh(id, args), which creates a canister and installs the job's artifact into it. Read the constraints before reaching for it:
| constraint | detail |
|---|---|
| operator-only, and you must own the job | it spends 1 T cycles of the canister's own balance, so unauthenticated it would be a direct drain |
| the caller becomes the sole controller | earlier it left the compiler as sole controller, so the deployer could not upgrade their own canister |
mode is #install | fresh install, not upgrade |
it installs job.wasm | which is the unlinked module — this helper is for the operator-driven pipeline, not a substitute for linking |
For a normal build, link first and install with your usual tooling.
Things you cannot do#
| Why | |
|---|---|
Deploy getArtifact output directly | It is unlinked. Validation and install both refuse. |
| Ask the compiler canister to link | There is no link method on it. Linking is a separate canister and a separate binary throughout. |
| Skip the linker for "simple" programs | The RTS is not optional; hello-world needs it too. |
Keep the name custom section through a link | Its function indices are stale once the RTS adds functions. moc drops it as well. |
| Upgrade a canister built by a linker that drops custom sections | The IC checks for the persistence marker and refuses. If upgrades start failing, suspect the linker, not the compiler. |
Next#
- How a build runs — what the compile produced and why it stops there.
- Your first on-chain build — the CLI does all of this for you.
- Building on-chain — where the local link sits in the trust story.