Wado

WEP: WIT Interoperability

Context

Wado has accumulated WIT-related design and implementation in pieces, driven by concrete needs (CLI world, HTTP service world, WASI P3 effects, library publishing). The pieces are scattered across multiple WEPs, and the end-to-end goal — being able to consume an arbitrary .wasm component (or .wit package) that the compiler has never seen before — has never been written down in one place.

This WEP collects the existing pieces, captures the current state of the implementation, and states the long-term goal explicitly. It also makes a shape-level decision (unifying effect and interface) that several earlier WEPs left open or implicitly inconsistent.

Existing WEPs in this area

WEP Scope
WIT and Wado Mapping Bidirectional type/structure mapping. export as the CM boundary (visibility model: Visibility WEP). Originally split interface and effect; superseded by the unification decision below.
World Conformance and Export Syntax contract <World>; declaration and export(World::name) mapping syntax.
TIR-Level CM Binding Synthesis Type-driven lift/lower binding synthesis at the TIR layer.
WIT Bundling in Component Binaries The producer side: embed WIT into output .wasm via component-type custom section.
WebAssembly Module Import Support Phase 1 (core wasm asset import) is delivered. Phase 2 (CM-boundary external .wasm) is now subsumed by this WEP.
Target WASI P3 Only The CM target is fixed to WASI Preview 3.

Goal

Make the Wado compiler fully WIT-driven so that an arbitrary CM component can be consumed without compiler changes. Concretely:

  1. The user writes use { Foo } from "./external.wasm" with { type: "wasm" }; (or depends on a packaged component via wado.toml).
  2. The compiler reads the component-type custom section embedded in the component (the section described by WIT Bundling; the producer can compute the WIT — wado wit, Phase 1 — but embedding it into the binary is Phase 2, not yet done).
  3. The use resolver constructs Wado IR (worlds, interfaces, resources, types) directly from that embedded WIT.
  4. The CM binding synthesis lifts/lowers values at the boundary based on that IR, without any per-component, per-world, or per-resource hand-coding in the compiler.

There is no separate registry of "known WASI modules". The set of supported worlds equals the set of WITs the compiler has parsed during this compilation.

Key Decision: Unify effect block declarations into interface

WIT's organizing primitive is interface: a named group of free functions, resources (with their methods), and types. Wado previously had effect as a parallel block-declaration keyword (effect Foo { ... }). For WIT interoperability the block form collapses into interface:

interface Stdout { ... }       // formerly effect Stdout { ... }

The with clause continues to take interface names. Effect tracking semantics are unchanged.

The effect keyword is retained for one purpose: polymorphic effect parameters (<effect E>). Here E is a type-level binder over effect rows — a different concept from a CM interface, and one WIT does not have an equivalent for. Keeping the keyword for this case makes the distinction explicit and avoids overloading interface with a meaning it does not have in WIT.

fn wrapper<effect E>(f: fn() with E) with E { f(); }

What this resolves

Cross-package disambiguation (resolved by omission)

WIT distinguishes wasi:filesystem/types from wasi:sockets/types by package. Wado handles this asymmetrically:

Result: import / export in worlds is always bare and unambiguous, and the proposed from "<package>" / as qualifications are unnecessary.

Pure interfaces

WIT has no purity annotation. An interface that contains only types (no functions) is simply not effectful — it never appears in a with clause, and users use its types directly. No new syntax is needed. An interface with functions is conservatively treated as effectful by the call site.

Superseded for imported .wasm components by Effect Reconstruction from CM Component Imports. A fused component's interface is effectful only insofar as the component's own host-leaf imports are — a purely-computational import needs no with. The conservative rule stands unchanged for host-satisfied (WASI) interfaces.

Migration Plan

The migration runs on a single feature branch and lands as one merge:

There is no compatibility shim and no deprecation period. Wado is pre-stable and this is a source-level rename plus a small registry change; a single landing keeps the codebase consistent.

Why no from "<package>" / as qualifications

The earlier draft of this WEP proposed cross-package qualifications (import { Types } from "wasi:filesystem", import { Types as SocketsTypes } from "wasi:sockets") for resolving local-name collisions in worlds. After auditing the stdlib and consumers, we removed that requirement:

If a future WIT file forces a non-removable collision (two pub interface declarations sharing a Wado-side name), reconsider; until then the bare form is unambiguous and minimal.

Non-Goals

Current State

What is already WIT-driven

Aspect Mechanism
World definitions Parsed from lib/wasi/**/worlds.wado; #[cm("...")] carries the FQ name.
Interface → CM import binding Each method declares #[cm_import("wasi:cli/stdout@...#write-via-stream")].
Resources, structs, enums, variants, flags, newtypes Registered from stdlib .wado parsing; #[cm(...)] carries CM-side names.
Entry function names (run, handle) Pulled from the world's declared export items, not hardcoded strings.
HTTP detection Namespace prefix wasi:http/ plus return-type shape (Result<Response, _>).
CM lift/lower synthesize_lift / synthesize_lower_to_flat are recursive and type-driven.
(interface, name) scoping for type lookups source_interface field disambiguates same-named types across interfaces (e.g. two ErrorCodes).

What is still hardcoded or specialized

Resolved since the first draft: HTTP handler specialization in codegen/component.rs is gone — CM imports/exports are now emitted from the WIR import plan and registry-derived descriptors with no HTTP/world literals (see §"Codegen Genericization", P1–P5).

Stale items already cleaned up

Producer Side: WIT Generation and Embedding

This section is the detailed design for the producer-side roadmap item "embed component-type in output". It implements the format spec from WIT Bundling and the Wado↔WIT mapping from WIT and Wado Mapping. Anything left open in those two documents (default behavior, CLI shape, where the generator hooks into the pipeline) is resolved here.

Position in the pipeline

WIT generation is a function of the frontend output. Describing a component's interface — declared interfaces, exported items, the active world, the type table — is fully determined by name resolution and type resolution. Monomorphization, lowering (TIR → NIR → WIR), optimization, and codegen do not contribute information that belongs in WIT.

The producer side therefore takes Semantics plus the target world FQ and produces:

Codegen is unaffected. The codegen.rs principle ("emits Package as is, without knowledge of earlier phases") still holds; the WIT bundle is appended as a postprocess step that takes the completed component bytes plus the precomputed WIT text.

Semantics additions

Semantics is the contract output of the frontend, so every fact needed to emit WIT lives on it. Phase 0 landed the registry accessors; Phase 1's emitter read interfaces and exported items off those registries and the loaded TIR directly, so no further Semantics accessor was required.

Phase 0 (landed):

Accessor Returns
Semantics::world_registry() Option<&'static WorldRegistry> — the parsed world table.
Semantics::cm_interface_registry() Option<&'static CmInterfaceRegistry> — the parsed CM interface table.

Both registries are already built by Elaborator::annotate_modules (which calls CmInterfaceRegistry::build_from_stdlib, an OnceLock-cached singleton) and live on AnnotateState. The accessors surface them without re-running stdlib parsing, so LSP, wado wit, and batch compilation share the same instance. FlatPackage / Package / NirPackage continue to carry the same &'static references they already held — Phase 0 added access without changing how the data is threaded.

Phase 1 (landed, simpler than planned): the proposed Semantics::interfaces() / exported_items() indices were not needed. emit_wit_text reads the exported items off the loaded TIR modules and the interface bodies off the Phase 0 registries directly, so no new Semantics accessor was added. The remaining inputs are WitEmitOptions fields threaded from the CLI: default_interface_name ([package].name or entry-file stem) and world_imports (the faithful WIR import plan — a project/codegen fact, not a frontend one).

The wir-build / codegen path continues to consume Package as today. The new WIT path is a sibling reader of Semantics; it does not touch Package.

Module layout

Two new modules under wado-compiler/src/:

wit_emit depends on wit-encoder for WIT text production. wit_bundle depends on wit_emit for text, parses it with wit-parser into a Resolve, encodes the component-type via wit-component::metadata::encode, and appends the custom section to the component the codegen already produced.

wit-encoder and wit-component are pinned in [workspace.dependencies] alongside the existing wit-parser (all at generation 0.246, matching wasmtime's tree). wado-compiler adds them to its own Cargo.toml in Phase 1.

Type mapping

Implements the table in WIT and Wado Mapping §"WIT to Wado Type Mapping". Direction here is Wado → WIT (the existing table reads in both directions, but the producer only needs one).

Out of scope for the first cut: closures, polymorphic effect parameters in exported signatures, generics with non-WIT-representable bounds. Encountering one in an exported signature is a compile error with a diagnostic that names the offending parameter and points at its source span.

Name conversion: Wado identifiers (distance, MyApi, set_level) become WIT kebab-case (distance, my-api, set-level). Conflicts after kebabification (e.g. two declarations colliding) are a compile error at WIT emit time with both source spans surfaced.

The structural constructors (option / list / tuple / result / future / stream) are assembled by one shared rule (wit_emit::assemble over a single-level CmShape classification). The two front-ends — resolved types (user exports) and CM-registry AST signatures (full-scope interface reconstruction) — only classify their input and render leaves, so the two paths cannot drift in how a shape becomes WIT. own<R>/borrow<R> map from a resource value / &resource respectively.

Interface grouping

Drives off the pub interface and export interface shapes already established in WIT and Wado Mapping:

Imported interface resolution and scope

For every import Foo; in the target world, the emitter looks up the referenced pub interface Foo via Semantics::interfaces, reads its #[cm("...")] FQ name, and uses that FQ name in world { import ...; }.

What the WIT document contains for each referenced interface depends on the requested emit scope:

Scope Behavior
full Inline the full body of every interface referenced by the world (user-authored and stdlib WASI/CM).
local Inline only user-authored interfaces. Stdlib references appear as import wasi:cli/stdout@<v>; with no body.

full matches the wit_component::metadata::encode toolchain convention and produces a self-describing component that wasm-tools component wit can decode without a registry. local produces a smaller WIT focused on the package's own contract; consumers need an external WIT registry to resolve wasi:* references. Both are well-defined; the choice is deployment-policy, not technical.

The default scope is full. The scope table above applies to wado wit text (wado wit --scope <full|local>).

Revised in the Phase 2 finding: scope does not apply to embedding. An embedded component-type must be self-contained, so it is always full; a local section is not encodable. The earlier plan for a [wit].scope manifest key and an --embed-wit=<scope> resolution order is therefore dropped — see §"Phase 2 finding" and the Phase 3 entry.

Stdlib interfaces under lib/wasi/** are emitted with the same machinery as user interfaces; there is no special-case "stdlib" code path. Each pub interface is a uniform building block.

Faithful imports: a WIR-level component interface plan

The world's import set must equal what the compiled component actually imports — otherwise the embedded component-type (Phase 2) misrepresents the binary. Deriving the set semantically (effect rows + type closure) is not faithful: it over-includes type-alias-only interfaces (wasi:clocks/types, duration = u64) and misses implicit imports (wasi:cli/stderr, written by the assert / panic path with no with clause).

Decision: build the complete import/export interface set once, as structured data, at the WIR layer (after DCE, so used_wasi_functions is populated), and have codegen merely emit it. This restores the codegen.rs principle ("emit Package as is, without knowledge of earlier phases"). wado wit and the Phase 2 embedder read the same plan; full-scope nested-package bodies stay a separate type-closure (which follows type aliases, so use duration resolves).

Done (R1–R3, each gated by the full E2E suite):

Embedding target and format

Wado emits a complete component in a single pass, so there is no intermediate "core module with bindings" step analogous to wit-bindgen + wasm-tools component new. The component-type custom section is therefore added to the outer component, not to a nested core module.

The section payload matches wit_component::metadata::encode() exactly:

  1. A serialized component binary typing the world.
  2. A wit-component-encoding subsection declaring UTF-8 (Wado's native string encoding).
  3. A producers subsection identifying the Wado compiler version.

Consumers run wasm-tools component wit output.wasm, but note (per the Phase 2 finding above) that for a Wado component this reconstructs WIT from the component's own type, not from the appended component-type section. The embedded section is consumed by tools that read it explicitly (wkg, wasm-tools metadata, relink flows) and decodes standalone as a WIT package. Round-trip verification therefore splits: (a) decode(component) matches wado wit semantically, and (b) the embedded payload decodes as a DecodedWasm::WitPackage. Both are covered by tests/wit_bundle.rs.

Embedding policy

Two roles, clearly separated:

Because Wado treats single-file scripts as first-class, wado compile embeds WIT by default — with or without a wado.toml. --no-embed-wit is the single, explicit opt-out. (The section is always the self-contained full closure; there is no scope knob — see the Phase 2 finding.)

-Os is the exception: it is the production build for frontend delivery (jco-transpiled to core Wasm + JS for the browser), where the WIT metadata is dead weight that never reaches a CM host. So -Os defaults to no embedding, exactly as if --no-embed-wit were passed. An explicit --embed-wit still forces embedding under -Os for the rare case that wants both the smallest symbols and a self-describing component.

Embedding is a property of producing a distributable artifact, so it applies to wado compile only. wado run, wado serve, and wado test compile to an ephemeral in-memory component and never embed WIT, keeping the inner dev loop small and fast.

CLI surfaces

wado wit — standalone WIT text:

wado wit file.wado                                 # stdout, default scope (full)
wado wit --scope local -o file.wit file.wado       # file output, user-only WIT
wado wit --world wasi:http/service file.wado       # pick the target world

Backed by wit_emit::emit_wit_text. Runs wado_compiler::semantics() — the public async Semantics-only entry, which parses, loads, and analyzes, then stops; no monomorphize, no lower, no codegen. Mirrors wado dump in pipeline depth, but carries no -O level: WIT is a pre-codegen fact, so the optimization level is irrelevant. Takes a single positional target — a .wado file or a directory (see "Input resolution"); passing more than one is an error. --world reuses the same flag and default (wasi:cli/command, overridable by the __DATA__ world) as wado compile. --scope is optional and defaults per the resolution order above. When the file has no world entry point (a library-shaped .wado with only pub interface / bare export items), the emitter produces an empty world — see "World-less libraries" under Open Design Questions.

wado compile — embed WIT in the compiled component (default on):

wado compile file.wado                  # embeds (default)
wado compile --no-embed-wit file.wado   # explicit opt-out
wado compile --embed-wit file.wado      # force on (e.g. under -Os)

Revised in the Phase 2 finding: the embedded section is always the self-contained full closure, so wado compile has no scope value. Both --embed-wit and --no-embed-wit take no value and are mutually exclusive. The --embed-wit=<scope> / [wit].scope knobs sketched in this section's earlier draft were dropped; local / full survives only on wado wit --scope (text output).

Input resolution

wado wit does not take a .wado file only. The positional input is a target that resolves through manifest::resolve_input:

Input Resolution
foo.wado A file is a single Wado source — analyze it directly.
a directory A directory is a Wado package — load <dir>/wado.toml and resolve the entry source from it.
(omitted) Discover the nearest wado.toml upward from the cwd.

file = source, dir = package is the whole rule — no third "manifest file" form. A bare wado.toml path is not a target; point at its directory instead. All three forms already work in resolve_input, and wado compile routes through the same helper, so wado wit and wado compile share one input model with no new plumbing.

When resolution goes through a manifest, the CLI loads it (via load_nearest_manifest) to source two inputs that single-file mode lacks: WitEmitOptions::default_interface_name from [package].name, and the --scope default from [wit].scope. A bare .wado file falls back to the file stem for the name and full for the scope.

World selection follows from the same resolution: --world wins when given; otherwise the manifest's declared entry picks both the source and the world, in precedence order commandservicelib. A lib-only project resolves to the library case (empty world; see "World-less libraries").

Implementation phases

Each phase ends with green E2E tests for the listed fixtures.

Phase 2 finding: the component already self-describes

A Wado artifact is a full Component Model component, not a core module, so it is already self-describing: wit_parser::decode (the wasm-tools component wit backend) reconstructs the WIT from the component's own typed CM imports/exports via its decode_component path — verified empirically on the CLI corpus. decode only consults a component-type payload when the whole file is a WIT-package blob (is_wit_package() keys off top-level component-type exports); a component-type custom section on an already-formed component is opaquely carried, never read by component wit.

The embedded section is therefore additive full-fidelity metadata, not the mechanism that makes the binary inspectable. Its value over the intrinsic type: the component's own type is always tree-shaken to the used surface, whereas the embedded payload carries the complete upstream interface bodies, exact package versions, and a producers record (the metadata::encode convention wkg / wasm-tools metadata / relink flows consume). The earlier framing in WIT Bundling ("a standalone .wasm cannot describe its own interface") holds for core modules, not for Wado's component output; the round-trip therefore splits in two: (a) decode(component) matches wado wit semantically, and (b) the embedded payload decodes as a WIT package.

Consequence for scope: an embedded section must be self-contained, because metadata::encode types the world against a fully-resolved Resolve. So embedding always emits the full interface closure; a local (registry-referencing) document does not re-parse standalone. This is a Wasm CM binary property (component types are structural and self-contained), not a Wado choice. local / full therefore stays a wado wit text concept (wado wit --scope) only. Consequently wado compile carries no scope knob: --embed-wit / --no-embed-wit take no value, and the originally-planned [wit].scope manifest override (Phase 3) is dropped — it would have nothing to tune for the binary.

Phase 3 — Manifest scope override: dropped, not implemented. Embedding is always the self-contained full closure (see the finding above), so there is no embed-time scope to put in wado.toml. wado wit --scope remains the only place local / full is meaningful (text output). WIT Bundling's status is reconciled to "implemented, default-on, no scope knob".

Open Design Questions

World-less libraries (wado compile --lib)

A .wado file with only export items and no world entry point (fn run / fn handle) is a library. It is declared by [package].lib in wado.toml and built with wado compile --lib ([package].namespace is required for --lib, and --lib is mutually exclusive with --world). The library world is synthesized from the entry module's export signatures and carried on Package.lib_world_info — it cannot live in the &'static stdlib WorldRegistry, so it is special-cased like the test world. Library exports use a synchronous lift (the core function returns the value directly), and the default allocator is freelist.

World naming: the anonymous root

A component's type is anonymous: the Component Model binary encodes it as componenttype ::= 0x41 vec(componentdecl) (Binary.md) — a bare list of imports/exports with no world name — and WIT models a world as "an equivalent of a component type" whose name is only a text-level label (WIT.md). wasm-tools component wit confirms this: decoding a Wado library component yields world root { … export <namespace>:<name>/<name> }. The world name is discarded on decode and re-synthesized as root.

So a library's world carries no identity; only its interface does. The identity consumers import is the default interface <namespace>:<name>/<name>@<version>. The library world is the anonymous root and is emitted as root (matching the decode convention), kept distinct from the default interface so the two never share one namespace:package/<name> slot — WIT worlds and interfaces occupy a single per-package namespace, so naming both after the package is a duplicate item error that silently drops the component-type embed. [package].name may therefore not be root. This splits two previously-conflated values: the world's emit name (root, textual only) and the default-interface FQ (ns:name/name, the codegen identity and the component's real export). The anonymous component binary is unaffected.

wado wit --lib renders this same shape (world root exporting the default interface), so the human-readable text and the embedded component-type agree.

Export grouping follows the same A/B rule the WIT producer applies (wit_emit, mirroring WEP: WIT and Wado Mapping → "Default Interface"):

The codegen mirrors this without world-specific special-casing:

  1. The keystone is registry-driven type resolution. The entry module's exported (and transitively referenced) named types are registered into CmInterfaceRegistry under the default-interface FQ. Both the export type plan (resolve_cm_export_type) and the CM type emitter resolve named types purely through this registry, so lib-local types need no special path once registered.
  2. The CM value-type emitter is the single recursive engine that already builds the full value surface (list/option/tuple/result/record/ variant/enum/flags/newtype/nested) for WASI imports. It is generalized over a type sink so it emits either into an imported/exported InstanceType or as top-level component defined types for direct world exports — one engine, no parallel export-side reimplementation.
  3. package-cm-catalog is the corpus enumerating the value-type ABI surface; tests/cm_catalog.rs round-trips a crafted value through every id_* export (lift(lower(x)) == x) at -O0/-O2, and the same fixture runs under the test world via e2e.rs.

wado wit (without --lib) still wraps a no-entry-point file's interface in the resolved default world (an otherwise empty world), which keeps its output valid, round-trippable WIT.

World structure faithfulness

WIT worlds are containers: a world bundles a set of imports and exports, and the same interface can appear in multiple worlds with different roles. The current Wado treatment flattens this — interfaces are globally visible, and the user imports them with use regardless of which world is targeted.

Levels of faithfulness to consider:

External WIT consumption realistically requires at least L2 and probably L3: without per-world scope, two unrelated worlds whose imports share a name (e.g. two Logger interfaces from different packages) would still need to be disambiguated only by the cross-package syntax above, which is workable but fragile if the user forgets to qualify.

contract declaration

WEP: World Conformance and Export Syntax defines the syntax. The parser does not implement it. Designing L2/L3 above means deciding the runtime behavior of contract before parser work starts.

"All methods" import form (resolved)

wado-from-idl now emits bare import Foo; (matching WIT's import stdout;), and the brace-block form has been removed from the parser. Per-method tree-shaking is driven by used_wasi_functions (populated from actual call sites), so nothing depends on the world declaration for which methods are eligible.

Roadmap

This WEP is a roadmap document. Each item below either has its own WEP or will get one when work starts.

Codegen Genericization: Removing HTTP/world Special-Casing

Done (P1–P5). codegen/component.rs now encodes CM imports/exports from the WIR import plan and registry-derived descriptors with no package == "http", namespace_prefix == "wasi:http/", returns_http_response, {pkg}-handler-result, or get_package_version("http") literals. HTTP and kiln (emit_kiln_world_types) fall out of one generic mechanism. Key pieces:

Scope was the stdlib-derived registry path only; external .wasm consumption is a separate item.

Consequences

Positive

Negative

Trade-offs

References