Wasm, WASI, WIT & the q64 component
Every component Qube you deploy to qubepods is a WebAssembly component
compiled from q64 source. This page is for the technically
curious: what the layers underneath are (Wasm, WASI, WIT, the component
model), how the q64 compiler produces a component, and — because nothing
demystifies a format like reading it byte by byte — a complete tear-down of
the smallest component we could build: one function, a + b, 115 bytes.
The four layers
Section titled “The four layers”WebAssembly (Wasm) — the machine
Section titled “WebAssembly (Wasm) — the machine”WebAssembly is a portable binary instruction format: a small, stack-based
virtual machine with linear memory, designed to run at near-native speed
inside a sandbox. A .wasm file is a module — functions, a memory,
and a list of exports — with no ambient access to anything. It cannot
open a file, read a clock, or make a network request unless the host
explicitly hands it a function to call. That deny-by-default posture is
why qubepods can run untrusted tenant code on shared infrastructure: the
sandbox is the format, not an add-on.
WASI — the system interface
Section titled “WASI — the system interface”A pure module can compute, but real programs need capabilities — storage,
clocks, randomness, sockets. WASI (WebAssembly System Interface) is the
standardized vocabulary for those: instead of one big POSIX-ish syscall
layer, it is a family of small, focused interfaces — wasi:keyvalue,
wasi:blobstore, wasi:config, wasi:clocks, and so on. A module doesn’t
link these; it imports them, and the host decides — per instance —
what implementation (if any) to supply. On qubepods, the platform is that
host: when your manifest binds a key-value store, the runtime satisfies the
qube’s wasi:keyvalue/store import with a store pinned to your project’s
identity. The code never names a namespace, database, or bucket.
WIT — the contract language
Section titled “WIT — the contract language”WIT (WebAssembly Interface Types) is the IDL of this world: a small,
readable language for describing interfaces — functions with named,
typed parameters, using real types (string, list<u8>, record,
result, s64) rather than raw integers. A world is a WIT document
that describes one component’s complete boundary: everything it imports,
everything it exports. Here is a real one — the whole contract of the
component we dissect below:
package qubepods-examples:adder;
world adder { // imports — derived capability set // (none — pure surface) // exports — public surface export add: func(a: s64, b: s64) -> s64;}You can audit a component from its world alone: this one imports nothing, so it provably cannot touch storage, network, or clocks — the strongest sandbox statement there is, checkable before a single instruction runs.
The component model — the packaging
Section titled “The component model — the packaging”A core Wasm module speaks only in machine types (i32, i64, f32,
f64) and raw memory. The component model wraps one or more core
modules into a component: a self-describing unit whose boundary is
typed in WIT terms. The wrapper’s job is the canonical ABI — the fixed
recipe for lifting core values into interface types (an i64 becomes an
s64; a string becomes a pointer + length that the host reads out of the
module’s memory) and lowering them back on the way in. Components
compose: one component’s typed import can be satisfied by another’s typed
export, without either seeing the other’s memory.
The q64 component
Section titled “The q64 component”q64 targets this stack directly rather than through a C-style toolchain:
- A qube compiles to a component.
qube build --componentemits the core module and the component that wraps it, plus the synthesized.witworld. The world is named from the qube’s identity inqube.json5(wit.package,wit.world), not from a filename. - Capabilities are inferred, not declared in code. q64 code reaches
the outside world only through
env—env.kv,env.blob,env.config,env.out. Eachenvface lowers to a WASI-family import in the emitted component (env.blob→q64:blob/store,env.config→wasi:config/store, …). Use no faces and the compiler derives an empty capability set — the “pure surface” you saw in the world above. - The surface is the contract. A library qube’s
pub fns become the world’s exports, with their q64 names and types carried through (i64⇄ WITs64).
qube vs. Qube — the case convention
Section titled “qube vs. Qube — the case convention”The capitalization is not decoration; it names the two kinds of thing that live in this ecosystem:
- A qube (lowercase) is a library:
type: "library"in its manifest, nomain. It exports a surface — a WIT world — for other qubes to link against. Theadderbelow is a qube. - A Qube (uppercase) is a deployment artifact:
type: "application", has amain, runnable. It’s what you ship withqube deployand what gets a*.qubepod.appURL on qubepods. - qubes (plural, lowercase) is the generic noun for either kind.
So: you publish a qube to the Continuum; you deploy a Qube to
qubepods. (The CLI is always lowercase monospace: qube.) Qube names are
snake_case dotted segments — qubepods.examples.adder — never hyphenated.
The Continuum — and how qubes bind together into a Qube
Section titled “The Continuum — and how qubes bind together into a Qube”The Continuum is the qube registry. Humans browse it at
continuum.q64.dev; the qube CLI talks to
its API at qubes.q64.dev. qube publish packs a qube’s archive,
computes its content address, and uploads it together with its synthesized
WIT world — the contract consumers resolve against.
A Qube is assembled from qubes through the manifest’s dependencies
block. Keys are full qube names (which double as module paths in code);
values are version ranges resolved through the Continuum, or local paths
during development:
dependencies: { "dev.q64.math": "^0.1", // resolved via the Continuum "dev.example.llm": { path: "../llm" }, // local, while you develop both}In source, the dependency is just a module:
import dev.q64.math.{add}
fn main { env.out(add(2, 3))}Underneath, this is component-model composition: the library qube’s
component exports add: func(a: s64, b: s64) -> s64, the consuming
Qube imports that same typed interface, and the linker satisfies one
with the other — types checked at the WIT boundary, no shared memory
between them. Resolved versions are pinned in qube.lock.
Two properties make this more than a package manager:
- Capabilities compose visibly. Every published qube carries its
compiler-derived capability set (a
capabilitiesfield the toolchain rewrites on publish, backed by a Wasm custom section — hand-edits are corrected, and the registry cross-checks uploads). A Qube’s effective capability set is the closure over everything it links: pull in a qube that reaches the network and your Qube’s contract says so.adder’s contribution to any closure is exactly nothing. - Remote qubes bind the same way. A dependency can also resolve to a
running qube’s wRPC address instead of linked code — the call site
looks the same, but crossing the wire is tracked as an effect
(
@wire), so even distribution is visible in the contract.
Building the smallest component
Section titled “Building the smallest component”The example is adder in the qubits repo
— a library qube whose entire source is:
pub fn add(a: i64, b: i64) -> i64 { a + b }and a manifest that names it and asks for a component:
{ name: "qubepods.examples.adder", type: "library", entry: "src/lib.q", component: { emit: true }, wit: { package: "qubepods-examples:adder", world: "adder", path: "synthesized" },}We compiled it with the prebuilt q64/qube binaries from the
q64 GitHub release (v0.0.3,
CLI 0.0.1 pre-alpha), checksums verified against the release’s
SHA256SUMS — you never build the compiler from source just to build a
qube. One command:
qube build --component# → target/debug/wasm64/qubepods.examples.adder.wasm (core module)# → target/debug/wasm64/qubepods.examples.adder.component.wasm (component)# → target/debug/wasm64/qubepods.examples.adder.wit (synthesized world)The artifact sizes:
| Artifact | Size |
|---|---|
| Core module | 56 bytes |
| Component | 115 bytes |
| WIT world (text) | 236 bytes |
Yes — the human-readable contract is bigger than either binary.
Byte analytics: the 56-byte core module
Section titled “Byte analytics: the 56-byte core module”A Wasm binary is a fixed 8-byte header followed by numbered sections,
each id, size, payload, with all integers in LEB128 (one byte while the
value fits in 7 bits — everything here does). The complete file:
offset bytes meaning────── ───────────────────── ────────────────────────────────────────────0x00 00 61 73 6d magic: "\0asm"0x04 01 00 00 00 version 1 — a core wasm module
── Type section ──0x08 01 07 section id 1, size 70x0A 01 1 type0x0B 60 function type0x0C 02 7e 7e 2 params: i64, i64 (0x7e = i64)0x0F 01 7e 1 result: i64 → (i64,i64)→i64
── Function section ──0x11 03 02 section id 3, size 20x13 01 00 1 function; func 0 has type 0
── Memory section ──0x15 05 04 section id 5, size 40x17 01 1 memory0x18 05 01 01 flags: has-max | memory64; min=1, max=1 page
── Export section ──0x1B 07 10 section id 7, size 160x1D 02 2 exports0x1E 06 6d 65 6d 6f 72 79 name "memory" (len 6)0x25 02 00 kind 2 = memory, index 00x27 03 61 64 64 name "add" (len 3)0x2B 00 00 kind 0 = function, index 0
── Code section ──0x2D 0a 09 section id 10, size 90x2F 01 07 1 body, 7 bytes0x31 00 0 local declarations0x32 20 00 local.get 0 (a)0x34 20 01 local.get 1 (b)0x36 7c i64.add ← the entire program0x37 0b endThe whole function is four instructions — 20 00 20 01 7c 0b — and the
return is implicit (a Wasm function returns whatever is on the stack at
end). Note the limits flag 0x05 at offset 0x18: the memory64 bit is
set, because this is a wasm64 build (--addr wasm64, the q64 default).
Byte analytics: the 115-byte component
Section titled “Byte analytics: the 115-byte component”The component has the same \0asm magic but version bytes 0d 00 01 00 —
version 13, layer 1 (layer 0 means core module). It then embeds the
entire 56-byte core module verbatim and wraps it in 59 bytes of envelope:
offset bytes meaning────── ───────────────────── ────────────────────────────────────────────0x00 00 61 73 6d magic0x04 0d 00 01 00 version 13, layer 1 = component
── Core module section ──0x08 01 38 section id 1, size 0x38 = 560x0A…0x41 the 56-byte core module above, byte for byte
── Core instance section ──0x42 02 04 section id 2, size 40x44 01 00 00 00 1 instance: instantiate module 0, 0 imports
── Alias section ──0x48 06 09 section id 6, size 90x4A 01 1 alias0x4B 00 00 sort: core function0x4D 01 00 target: core-instance 0's export…0x4F 03 61 64 64 …named "add"
── Type section ──0x53 07 0b section id 7, size 110x55 01 40 1 type: function0x57 02 2 named params0x58 01 61 78 "a": s64 (0x78 = s64)0x5B 01 62 78 "b": s640x5E 00 78 result: s64 → func(a,b) -> s64
── Canon section ──0x60 08 06 section id 8, size 60x62 01 00 00 1 definition: canon lift0x65 00 00 00 core func 0, 0 options, type 0
── Export section ──0x68 0b 09 section id 11, size 90x6A 01 1 export0x6B 00 03 61 64 64 name "add"0x70 01 00 00 sort function, index 0, no type ascriptionRead as a sentence: embed the core module, instantiate it, alias its
add export, lift that through the canonical ABI as
func(a: s64, b: s64) -> s64, and export the result as add. The canon
lift has zero options — scalars cross the boundary in registers, so no
memory or realloc needs to be named. Every arithmetic byte in the file
is still the single 7c (i64.add) at offset 0x36 inside the embedded
module. And this tiny wrapper is the same machinery that scales up: a
real qube’s component differs only in having more aliases, imports to
satisfy (wasi:keyvalue, …), and richer types to lift.
Is 56 bytes optimal?
Section titled “Is 56 bytes optimal?”Almost. add never touches linear memory, yet the module declares one
page and exports it — hand-writing the true minimum gives a 41-byte
module that instantiates and computes identically:
| Structure | Compiler | Minimum | Delta |
|---|---|---|---|
| Header + type + function + code | 32 B | 32 B | 0 |
| Memory section (1 page, min = max) | 6 B | — | +6 |
| Export section | 18 B | 9 B | +9 (the "memory" entry) |
| Total | 56 B | 41 B | +15 |
Everything else is at the floor: no name/custom sections, every LEB128
length a single byte, canonical section order, and no shorter encoding of
a + b exists. The 15 bytes are a deliberate uniformity trade-off, not
waste: the canonical ABI requires a memory (plus a realloc) the moment
anything non-scalar — a string, a list, a record — crosses the
boundary. A pure-scalar surface is the special case, so q64 emits every
module with its memory, capped at min = max = 1 page. Cost: 15 bytes in
the file and one 64 KiB page at instantiation.
Run it yourself
Section titled “Run it yourself”The core module runs in any Wasm runtime — in Node, i64 crosses the
JS boundary as BigInt:
import { readFileSync } from "node:fs";
const bytes = readFileSync("target/debug/wasm64/qubepods.examples.adder.wasm");const { instance } = await WebAssembly.instantiate(bytes, {});
// The `n` suffix is a JS BigInt literal — a type marker, not a unit.// Wasm i64 maps to BigInt at the JS boundary, because a plain Number// (an IEEE-754 double) only holds integers exactly up to 2^53 - 1;// passing a bare `2` here would throw a TypeError.instance.exports.add(2n, 3n); // 5ninstance.exports.add(1000000000000n, 2345n); // 1000000002345nNo import object — {} — because the world imports nothing. That is the
whole q64 + Wasm story in one line: the contract said this code needs no
capabilities, and the empty braces prove it.
Next: Binding qubes into a Qube — a second
qube that imports the monotonic clock, both bound into a runnable Qube that
times an add() call, and the emitted command component mapped byte by byte.