Status: Live consumer spec for the native Zig API. ADR-0109
Accepted 2026-05-25 authorizes the rewrite; ROADMAP §10 /
10.J carries the implementation cycles.
Audience: Zig consumers embedding zwasm v2 as a Wasm runtime
library (ClojureWasm v1 dogfooding, and any other Zig project).
Implementation status (2026-06-06 amend): SHIPPED. The §10 /
10.J rewrite is complete — the native facade (Engine / Module /
Linker / Instance / TypedFunc / Memory / Global / Table /
Caller + full 12-variant Trap + allocator strict-pass) lives in
src/zwasm/*.zig and is the primary surface. Phase-16 (feature-complete)
follow-ups added module introspection (Module.imports/.exports),
Memory.grow/.sliceAt, Engine.linker(), and
Linker.defineInstance. The signatures below are the original
target sketch; where a shipped signature diverges (notably the
introspection methods take an allocator + are fallible, since the
import/export sections are decoded on demand), the source is
authoritative — read src/zwasm/<type>.zig.
This doc is self-contained: read top-to-bottom to understand the consumer-facing contract without needing to read the ADRs. References at the bottom point to ADRs / code for deeper dives.
Five principles drive every shape below:
- Native Zig is the primary surface; c_api is a separate
downstream layer. The Zig API does not delegate to
wasm-c-api internally. The c_api binding (
src/api/) is for cross-language consumers and stays as a Zone-3 sibling. - Allocator strict-pass. Every alloc goes through the
caller-provided
std.mem.Allocator. Noc_allocatorfallback, no hidden globals. - Comptime-typed signatures are the central UX. Most
consumer code uses
instance.typedFunc(fn(i32, i32) i32, "add")rather than rawValueslices. The runtime marshals. - Wasm linear memory is exposed as a bounds-checked
[]u8slice view. No double allocation: host alloc and Wasm linear memory are separate by construction, and the host gets a slice into the Wasm side rather than a copy. - Trap is a narrow error union, not a catchall. Every
spec-defined trap variant (
IntDivByZero,OutOfBoundsLoad,StackOverflow, etc.) is preserved through the API boundary.
const zwasm = @import("zwasm");
// Top-level: Engine
pub const Engine = struct {
pub fn init(alloc: std.mem.Allocator, opts: InitOpts) !Engine;
pub fn deinit(self: *Engine) void;
pub fn compile(self: *Engine, bytes: []const u8) !Module;
pub fn linker(self: *Engine) Linker;
};
// Module: compile-once, instantiate-many
pub const Module = struct {
pub fn deinit(self: *Module) void;
// Introspection (shipped): allocator-taking + fallible (the import/
// export sections are decoded on demand). Result owns its strings;
// call `.deinit()`. `ModuleImports.items: []const ImportItem`
// (module+name+kind); `ModuleExports.items: []const ExportItem`
// (name+kind). `ExternKind = { func, table, memory, global, tag }`.
pub fn imports(self: *const Module, gpa: Allocator) IntrospectError!ModuleImports;
pub fn exports(self: *const Module, gpa: Allocator) IntrospectError!ModuleExports;
};
// Linker: builder for imports
pub const Linker = struct {
pub fn defineFunc(self: *Linker, mod: []const u8, name: []const u8, func: anytype) !void;
pub fn defineMemory(self: *Linker, mod: []const u8, name: []const u8, mem: *Memory) !void;
pub fn defineGlobal(self: *Linker, mod: []const u8, name: []const u8, g: *Global) !void;
pub fn defineTable(self: *Linker, mod: []const u8, name: []const u8, t: *Table) !void;
pub fn defineInstance(self: *Linker, mod: []const u8, inst: *Instance) !void;
pub fn defineWasi(self: *Linker, cfg: WasiConfig) !void;
pub fn instantiate(self: *Linker, module: Module) !Instance;
pub fn deinit(self: *Linker) void;
};
// Instance: runnable Wasm module
pub const Instance = struct {
pub fn deinit(self: *Instance) void;
pub fn typedFunc(self: *Instance, comptime Sig: type, name: []const u8) !TypedFunc(Sig);
pub fn call(self: *Instance, comptime Sig: type, name: []const u8, args: anytype) !ReturnOf(Sig);
pub fn invoke(self: *Instance, name: []const u8, args: []const Value, results: []Value) !void;
pub fn memory(self: *Instance) ?*Memory;
pub fn global(self: *Instance, name: []const u8) ?*Global;
pub fn table(self: *Instance, name: []const u8) ?*Table;
};
// Typed function handle (cache for hot-path calls)
pub fn TypedFunc(comptime Sig: type) type { ... }
// Wasm linear memory view
pub const Memory = struct {
pub fn slice(self: *Memory) []u8;
pub fn sliceAt(self: *Memory, offset: u32, len: u32) ![]u8;
pub fn readBytes(self: *Memory, offset: u32, len: u32) ![]u8;
pub fn writeBytes(self: *Memory, offset: u32, data: []const u8) !void;
pub fn read(self: *Memory, comptime T: type, offset: u32) !T;
pub fn write(self: *Memory, offset: u32, value: anytype) !void;
pub fn grow(self: Memory, delta: u32) ?u32; // → prev pages, null = refused (no trap)
pub fn size(self: *Memory) u32; // in pages (64 KiB)
pub fn grow(self: *Memory, delta_pages: u32) !u32;
};
// Untagged 16-byte slot per ADR-0110 (v128 first-class; no separate V128 type)
pub const Value = extern union {
bits128: u128,
v128: [16]u8 align(16),
bits64: u64,
i32: i32, u32: u32, i64: i64, u64: u64,
f32_bits: u32, f64_bits: u64,
ref: u64,
};
pub const ValueKind = enum { i32, i64, f32, f64, v128, funcref, externref };
// Trap — full spec set, no catchall
pub const Trap = error{
Unreachable, IntOverflow, IntDivByZero, InvalidConversionToInt,
OutOfBoundsLoad, OutOfBoundsStore, OutOfBoundsTableAccess,
UninitializedElement, IndirectCallTypeMismatch,
StackOverflow, CallStackExhausted, OutOfMemory,
};
// Host function context (passed as first arg of host funcs)
pub const Caller = struct {
pub fn engine(self: *Caller) *Engine;
pub fn memory(self: *Caller) ?*Memory; // calling instance's memory
pub fn instance(self: *Caller) *Instance;
pub fn alloc(self: *Caller) std.mem.Allocator;
};const std = @import("std");
const zwasm = @import("zwasm");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
var engine = try zwasm.Engine.init(gpa.allocator(), .{});
defer engine.deinit();
const wasm_bytes = @embedFile("hello.wasm");
var module = try engine.compile(wasm_bytes);
defer module.deinit();
var linker = engine.linker();
defer linker.deinit();
var instance = try linker.instantiate(module);
defer instance.deinit();
const add = try instance.typedFunc(fn(i32, i32) i32, "add");
const sum = try add.call(.{ 2, 3 });
std.debug.print("2 + 3 = {d}\n", .{sum});
}fn hostPrint(caller: *zwasm.Caller, ptr: u32, len: u32) void {
const mem = caller.memory() orelse return;
const s = mem.sliceAt(ptr, len) catch return;
std.debug.print("{s}\n", .{s});
}
fn hostAbort(_: *zwasm.Caller, code: i32) noreturn {
std.process.exit(@intCast(code));
}
// ...
try linker.defineFunc("env", "print", hostPrint); // signature inferred
try linker.defineFunc("env", "abort", hostAbort);
const inst = try linker.instantiate(module);The runtime infers each Wasm import signature from the Zig
function's type via @typeInfo(.@"fn"). Mismatches between
the host function signature and the Wasm import declaration
fail at instantiate() time with a typed error.
The first parameter of a host function must be *Caller —
this gives the host code access to the calling instance's
memory, allocator, and runtime state. Subsequent parameters
must be i32 / u32 / i64 / u64 / f32 / f64 (and in the
future [16]u8 / u128 for v128 + ?u64 for ref types
per the §4 Value union; no separate V128 type per ADR-0110).
// Wasm: (func (export "divmod") (param i32 i32) (result i32 i32) ...)
const divmod = try instance.typedFunc(fn(i32, i32) struct { i32, i32 }, "divmod");
const r = try divmod.call(.{ 10, 3 });
std.debug.print("quot={d} rem={d}\n", .{ r[0], r[1] });
// Or named tuple fields via Zig struct
const DivMod = struct { quot: i32, rem: i32 };
const dm = try instance.typedFunc(fn(i32, i32) DivMod, "divmod");
const r2 = try dm.call(.{ 10, 3 });
std.debug.print("quot={d} rem={d}\n", .{ r2.quot, r2.rem });const mem = instance.memory() orelse return error.NoMemory;
// Zero-copy view of the entire linear memory
const all = mem.slice();
std.debug.print("memory size: {d} bytes\n", .{all.len});
// Bounds-checked slice helper
const window = try mem.sliceAt(0x1000, 256);
@memcpy(window[0..5], "hello");
// Typed read/write (little-endian, alignment-respecting)
try mem.write(0x2000, @as(i32, 42));
const back = try mem.read(i32, 0x2000);
// String pattern: write to Wasm-side allocator, then call
const wasm_alloc = try instance.typedFunc(fn(u32) u32, "alloc");
const wasm_free = try instance.typedFunc(fn(u32, u32) void, "free");
const greet = try instance.typedFunc(fn(u32, u32) void, "greet");
const msg = "hello from Zig";
const ptr = try wasm_alloc.call(.{ @intCast(msg.len) });
defer wasm_free.call(.{ ptr, @intCast(msg.len) }) catch {};
try mem.writeBytes(ptr, msg);
try greet.call(.{ ptr, @intCast(msg.len) });Memory management contract:
mem.slice()returns a[]u8that aliases the Wasm linear memory directly. No copy, no extra alloc.- The slice is invalidated by
memory.grow()and by re-entering Wasm code that may grow memory. Hold it only across non-Wasm-calling Zig sequences. - Host alloc (the Engine's
std.mem.Allocator) and Wasm linear memory are separate by construction. Host pointers cannot be passed to Wasm directly (sandbox); data must be copied into Wasm memory (or pre-loaded at instantiation time via imports). - Wasm-side alloc/free (via exported functions) is the responsibility of the Wasm module. The host calls these to allocate Wasm-side buffers but must free them with the corresponding deallocator.
For cases where the function signature isn't known at comptime (REPL, inspector tools, dynamic dispatch):
var args = [_]zwasm.Value{
.{ .i32 = 10 },
.{ .i32 = 3 },
};
var results: [2]zwasm.Value = undefined;
try instance.invoke("divmod", &args, &results);
// results[0].i32 == 3, results[1].i32 == 1The caller is responsible for matching args / results
counts and types to the Wasm function signature. Type
mismatches surface as error.ArgTypeMismatch /
error.ResultTypeMismatch.
const div = try instance.typedFunc(fn(i32, i32) i32, "div");
const r = div.call(.{ 10, 0 }) catch |err| switch (err) {
error.IntDivByZero => return error.UserDivisionError,
error.IntOverflow => return error.UserOverflowError,
error.OutOfBoundsLoad => unreachable, // div doesn't load
error.StackOverflow => return error.UserStackError,
else => return err,
};All spec-defined trap variants from Trap are preserved
through TypedFunc.call, Instance.invoke, and Instance.call.
The error union is the union of (export-lookup errors,
arg/result marshal errors, Trap).
var inst_a = try linker_a.instantiate(module_a);
defer inst_a.deinit();
// Build a second linker that shares inst_a's memory
var linker_b = engine.linker();
defer linker_b.deinit();
try linker_b.defineMemory("env", "shared_mem", inst_a.memory().?);
var inst_b = try linker_b.instantiate(module_b);
defer inst_b.deinit();
// inst_a and inst_b now share the same linear memory.As-built WasiConfig (src/zwasm/linker.zig) carries args / envs /
preopens (+ io); after defineWasi every wasi_snapshot_preview1 import in
the module is satisfied as a unit:
try linker.defineWasi(.{
.args = &.{ "prog", "arg1", "arg2" },
.envs = &.{ .{ .name = "LANG", .value = "C" } }, // → environ_get
.io = my_io, // your std.Io
.preopens = &.{ .{ .host_path = ".", .guest_path = "/" } }, // → path_open
});
// All wasi_snapshot_preview1 imports are defined as a unit.
const inst = try linker.instantiate(module);args / envs / preopens are settable on the Zig WasiConfig (preopens are
opened at instantiate via the caller-provided io; the embedder owns the event
loop). Only stdin / stdout / stderr capture is still facade-unwired (D-251) —
use the C API for stdio redirection.
Component support is gated by the WASI tier -Dwasi>=p2 (default p2, so on
by default; -Dwasi=p1 opts out — ADR-0193 folded the former -Dcomponent
flag into the WASI version axis). The host surface lives at zwasm.feature.component.host
(src/api/component.zig); the component binary is self-describing —
no .wit sidecar is needed (CWFS ADR-0135 "the binary IS the interface").
const comp = zwasm.feature.component.host;
// 1. Introspect: what typed funcs does this component export?
// Includes interface-nested funcs, path-qualified `<iface>#<func>`
// (e.g. "zwasm:restest/counter-api#[method]counter.get") — names
// are returned in exactly the form `invokeTyped` accepts.
var ci = try comp.instantiate(&engine, alloc, bytes); // single-module component
defer ci.deinit();
const funcs = try ci.exportedFuncs(alloc); // []ExportedFunc{name, params, result}
defer zwasm.feature.component.types.TypeInfo.freeExportedFuncs(alloc, funcs);
// 2. Typed invoke through the canonical ABI. Args are `ComponentValue`
// trees validated against the export's WIT signature.
const out = try ci.invokeTyped("greet", &.{.{ .string = "zwasm" }}, alloc);
defer if (out) |o| o.deinit(alloc); // caller owns the result tree
// out.?.string == "Hello, zwasm!"Real-toolchain components (wit-bindgen Rust / TinyGo) import wasi:*, so
they go through the general graph builder instead of instantiate:
var host = try zwasm.wasi.host.Host.init(alloc);
defer host.deinit();
host.io = io;
var built = try comp.buildWasiP2Component(&engine, alloc, bytes, &host);
defer built.deinit();
const r = try comp.invokeTypedBuilt(&built, "process", &args, alloc);comp.open(engine, alloc, bytes, host, opts) (REQ-1) is the UNIFIED entry: it
auto-selects single-module vs the WASI-P2 / multi-instance graph (structural
— a component with host imports or > 1 embedded core module needs the graph)
and returns one Opened handle that drives both paths through the same
methods (exportedFuncs / invokeTyped / resolveFuncSig / dropResource /
typeInfo / deinit) — no try-catch fallback, no two-way dispatch.
comp.componentNeedsWasi(alloc, bytes) is the cheap pre-instantiation
predicate.
var opened = try comp.open(&engine, alloc, bytes, &host, .{});
defer opened.deinit();
const out = try opened.invokeTyped("greet", &.{.{ .string = "zwasm" }}, alloc);
// Guest-defined resources: a host that caches an `own` handle frees it via
// try opened.dropResource(handle); // REQ-5 — removes the handle + runs the
// // declared destructor (own handles)ComponentValue (src/feature/component/value.zig) mirrors the WIT value
model: record keeps field names (borrowed from the instance's decoded
type info), option/result/tuple stay specialized (the canonical ABI's
despecialization is internal), and deinit(alloc) frees the whole tree.
Rich shapes round-trip — the pinned proof is
record{list<u32>, string} ⇄ result<record, string> over a committed
wit-bindgen fixture (test/component/typed_payload.wasm).
A lifted .@"enum" / .variant / .flags carries its LABEL(s) (REQ-2,
borrowed from the decoded type info): .@"enum" = {index, label},
.variant = {case, case_name, payload}, .flags = {bits, labels} (bit i
⇔ labels[i]). On a host-constructed INPUT value the label fields are
empty and invoke dispatches by the numeric ordinal/bits — map a host label
to its ordinal with resolveFuncSig (below), whose WitType enum/flags
arms list the labels in order.
resolveFuncSig(arena, name) / resolveType(arena, info, vt) (REQ-3,
feature/component/wit_type.zig) resolve an export's signature to a
WitType TYPE tree — the type counterpart to ComponentValue:
specialization-preserving (option/result/tuple distinct) and
label-carrying (enum_/variant/flags), with the decoded 2-space
resolution rule hidden. Both ComponentInstance and BuiltComponent
expose resolveFuncSig.
Component instantiation takes a budget: instantiate(engine, alloc, bytes, opts) / buildWasiP2Component(…, opts) accept
component.InstantiateOpts{fuel, max_memory_pages} (REQ-4); .{} = the
default budget.
Guest-defined RESOURCES (D-322) work through the same surface: a
constructor returns ComponentValue.own (an opaque handle into the
component instance's table); methods take .borrow handles (the
runtime translates handle→rep per the canonical ABI's owner-component
rule). Func exports inside an exported interface are addressed with an
instance path:
const h = (try comp.invokeTypedBuilt(&built,
"zwasm:restest/counter-api#[constructor]counter",
&.{.{ .u32 = 5 }}, alloc)).?; // h == .own
const v = (try comp.invokeTypedBuilt(&built,
"zwasm:restest/counter-api#[method]counter.get",
&.{.{ .borrow = h.own }}, alloc)).?; // v.u32 == 5Manifest-driven coverage: the component spec corpus runner's
assert_typed directive (test/spec/component_model_assert_runner.zig)
drives this same path from text, e.g.
assert_typed process ({xs: [1, 2, 3], label: "sum"}) -> ok({xs: [1, 2, 3, 6], label: "sum!"}).
Engine choice is per-instance, at instantiate time (never per-call); both
engines coexist in one build. The Zig facade carries it on
Module.InstantiateOpts.engine:
var jit_inst = try module.instantiate(.{ .engine = .jit }); // native JIT
var int_inst = try module.instantiate(.{ .engine = .interp }); // interpreter
var auto_inst = try module.instantiate(.{}); // .auto (default)EngineKind = enum { auto, jit, interp }. .auto is the default and is
documented to resolve to the runtime's best choice — currently the interpreter
until the JIT host-import/WASI bridge lands, then JIT-on-arches-with-a-backend
(interp fallback on a JIT-less arch). Because the default's meaning is what is
specified (not a fixed engine), the resolution can change without an API break.
An explicit .jit on a JIT-less arch fails instantiation rather than silently
downgrading. The call boundary (invoke / typedFunc().call()) is
engine-agnostic — identical args/results contract across engines (cw runs a
dual-engine differential oracle on exactly this property).
The C ABI mirrors this with the zwasm_instance_new_ex(store, module, imports, trap_out, engine_kind) extension (include/zwasm.h; ZWASM_ENGINE_AUTO=0 /
JIT / INTERP) — stock wasm_instance_new is .auto. Worked end-to-end
mini-consumers: docs/examples/zig_host/jit_engine.zig + docs/examples/c_host/…jit_engine.c
(test-c-api-conformance), each instantiating engine=jit and calling a
multi-arg export plus a SIMD-body export (SIMD executes on the JIT).
The Value type is an untagged extern union of width 16
bytes (matching the internal JIT slot post-ADR-0110 Accept
9204847a 2026-05-24). v128 is first-class in the union —
no separate V128 type. NaN-boxing-friendly: float values
store their IEEE-754 bit pattern in f32_bits / f64_bits
(no canonicalization at the slot level — canonicalization
happens only at Wasm op boundaries that the spec requires).
pub const Value = extern union {
// 16-byte raw view (largest variant; v128 reads/writes go here)
bits128: u128,
v128: [16]u8 align(16),
// 8-byte scalar variants (occupy low 8 bytes; upper 8 zero by convention)
bits64: u64,
i32: i32, u32: u32,
i64: i64, u64: u64,
f32_bits: u32, // IEEE-754 bit pattern (32 bits in low half)
f64_bits: u64, // IEEE-754 bit pattern
ref: u64, // funcref/externref, see §4.1
};The internal JIT-emitted code reads/writes a single 16-byte
cell per Value slot (uniform stride; per ADR-0110 §"Decision"
the idx * 16 stride is what makes globals / locals
pointer-aliasable across instances + Q-reg / MOVUPS-friendly
on both arm64 and x86_64). The facade exposes the same union
so consumer code can address any variant without a separate
v128 type.
Caller responsibility: the union has no runtime tag. Wasm type is determined by the function signature; the caller must select the correct field. Mismatched-field reads are silent UB at the Wasm spec level (the runtime will use the bits as the signature-declared type).
- funcref:
reffield holds@intFromPtr(*const FuncEntity)for non-null references. The pointer carries source-instance identity (enabling cross-instancecall_indirect).null_refis encoded asref = 0—c_allocator.alloccannot return address 0 on supported platforms, so 0 is a safe sentinel. - externref:
reffield holds an opaque 64-bit host handle. Same0sentinel for null.
v128 is the union's natural width — no special-case
wrapper type. For TypedFunc signatures consumers use
[16]u8 / u128 directly (or a thin V128 alias in
consumer code if they prefer a named type; zwasm itself does
not export one).
For the c_api boundary v128 is permanently NOT exposed
(spec-prohibited per wasm-c-api include/wasm.h:329-338;
see .dev/lessons/2026-05-24-c_api-v128-spec-boundary.md).
The native Zig API is the only consumer-visible v128 access
path.
For consumers (notably ClojureWasm v1) that wish to NaN-box their own value representation inside the Wasm float slot:
- Float values pass through the API as bit patterns
(
f32_bits/f64_bits). zwasm does NOT canonicalize NaNs at the Value boundary — canonicalization happens only insidef32.add/f64.div/ etc. handlers per Wasm §6.2.3 spec requirement. - Therefore, NaN payloads survive round-trips through
typedFunc.callandinvokeas long as the Wasm function itself doesn't perform an op that demands canonicalization. - The full 64 bits of
f64_bits(or 32 bits off32_bits) are usable for tagging. Sign bit, exponent bits, and payload bits are all preserved across the boundary.
| Component | Current src/zwasm.zig |
Target (this doc) |
|---|---|---|
| Top-level | Runtime (wraps wasm_engine + wasm_store) |
Engine (internal physical struct renamed JitRuntime to preserve JIT ABI surface) |
| Module load | Module.parse(rt, bytes) (calls wasm_module_new) |
engine.compile(bytes) |
| Imports | imports not in opts | Linker builder (InstantiateOpts now carries fuel/max_memory_pages budgets, ADR-0179) |
| Typed call | none (114 internal callXxx_yyy) |
TypedFunc(Sig) comptime-generic |
| Memory access | none | Memory.slice() + helpers |
| Value type | tagged union(enum) (~16+ bytes, v128 = u128) |
untagged extern union (uniform 16 bytes per ADR-0110; v128 first-class — no separate V128 type) |
| Trap | error.Trap catchall in InvokeError |
full Trap error set preserved |
| Allocator | accepted but ignored (c_allocator used) |
strict-pass through Engine.init |
| WASI | none in facade | linker.defineWasi(cfg) |
| Host functions | none in facade | linker.defineFunc(mod, name, zigFn) with comptime marshal |
The rewrite touches:
src/zwasm.zig(~500 line replacement)- New
src/api/host_func_marshal.zig(comptime fn-type → Wasm-sig adapter generator) - New
src/api/linker.zig(Zone-2-or-3 builder; positioning TBD per ADR-0109) - New
src/api/memory_view.zig(slice view over Wasm linear memory) - Internal
Runtime→JitRuntimerename (physical struct stays — ABI is JIT-emitted-code-stable)
Estimated 6-8 cycles of autonomous work, parallelizable.
- Multi-result return shape: the spec defaults to
struct { i32, i32 }(anonymous tuple) for multi-result. Does cw v1 prefer named fields (struct { quot: i32, rem: i32 }) as the primary shape? Both work; the question is which is the documented "blessed" pattern. - Caller as first arg of host funcs: required (above shows
fn(caller: *Caller, ...))? Or optional (omit when not needed)? Required is simpler to teach; optional is less boilerplate for pure functions. - Memory invalidation on grow: should
mem.slice()return a slice that grows alongside? Or a snapshot at-call-time? Snapshot is simpler + matches Wasm spec (eachmemory.growmay relocate); growth-tracking slice would require pointer dereference per access. - WasiConfig granularity: full
defineWasi(cfg)all-at-once vs per-syscalldefineWasiFd / defineWasiClock / .... CW likely wants the bulk path; embedded/restricted hosts might want per-syscall. - TypedFunc cache lifetime: tied to
*Instance(must re-lookup afterdefineFuncadds host functions)? Or stable across the Instance's lifetime? ?u64for ref-typed args:funcref/externrefas host-func parameters —?u64(nullable raw handle) or typedFuncRef/ExternRefwrappers?
cw v1 should raise these (and any others discovered while prototyping against this spec) so they get fixed before impl lands.
- ADRs:
- ADR-0014 — Allocator + zombie-instance contract.
- ADR-0025 — Zig library facade (the originally-proposed minimum subset; superseded by ADR-0109 for the target shape).
- ADR-0052 — Globals representation (scalar-only today).
- ADR-0061 — Reftype 8-byte slot encoding.
- ADR-0106 — Multi-result return convention (wrapper thunk).
- ADR-0107 — Byte-buffer globals for v128 cross-module (Proposed).
- ADR-0109 — This design's formal record (Proposed; supersedes ADR-0025 minimum-subset target).
- Code (current state):
src/zwasm.zig— current facade (c_api veneer).src/runtime/value.zig— internalValueextern union.src/runtime/trap.zig— full Trap error set.src/runtime/runtime.zig::Runtime— physical struct (will rename toJitRuntime).src/api/instance.zig— wasm-c-api binding (stays as Zone-3 sibling, not deleted).src/engine/codegen/shared/entry.zig— 114-entry typed helper catalog (internal; TypedFunc comptime layer replaces the consumer-facing equivalent).
- Industry precedents surveyed:
- wasmtime (Rust):
Engine+Store<T>+Linker<T>+TypedFunc<Params, Results>. - wasmer (Rust):
Engine+Store+Imports(viaimports!macro) +TypedFunction<Args, Rets>. - wasmi (Rust): sister of wasmtime, same shape.
- wasm3 (C):
IM3Environment(engine) +IM3Runtime(per-execution state — distinct from "Engine"). - WAMR (C):
wasm_engine_t+wasm_runtime_init. - zware (Zig):
Store+Instance(noEngine). - v1 zwasm (Zig, predecessor):
WasmModule.load(alloc, bytes)— 1-step, host imports inline vialoadWithImports. Predates the Linker pattern; this spec inherits the allocator-strict-pass + 1-step intuition while adopting the Linker for reuse.
- wasmtime (Rust):
- 2026-05-24 — Initial draft. Conversation-derived from Phase 9 close cycle 35 discussion: c_api → native-Zig inversion + v1-shape inspiration tempered by first-principles analysis (Engine + Linker + TypedFunc via Zig fn type + Memory slice view + NaN-boxing-friendly Value).
- 2026-05-25 — §4 Value section rewritten for ADR-0110
Accept (
9204847a2026-05-24): internal Value is now uniform 16-byte with v128 first-class; the original "8-byte slot + separateV12816-byte struct" split is obsolete. The facade exposes a singleextern unionwith all variants (bits128/v128/bits64/i32/i64/f32_bits/f64_bits/ref). §5 Comparison table updated to match. v128 stays permanently NOT exposed through the c_api boundary (spec-prohibited perinclude/wasm.h:329-338); the native Zig API is the only v128-typed consumer path. - 2026-05-25 — ADR-0109 Accepted (Status: Proposed → Accepted, user collab review at Phase 10 open). This doc is the live consumer spec; impl scheduled as ROADMAP §10 / 10.J. Pre-impl investigation + execution plan + test strategy (subagent-driven) is the next chunk after the amend round; the resulting plan doc gates the first J.* impl chunk.
- 2026-06-13 — §3.9 added (Component Model typed invoke,
as-built per ADR-0183):
exportedFuncsintrospection +invokeTyped/invokeTypedBuilt+ComponentValueownership contract + the corpusassert_typedpointer. - 2026-06-13 — §3.9 extended (campaign close): guest-defined
resources — own/borrow
ComponentValuehandles + the<iface>#<func>instance-path addressing.