Wado

WEP: Kiln — Keyed IDL Lowering Notation

Context

Wado is a language that is frequently expected to talk to schema-defined external systems: protocol buffers, JSON-RPC, gRPC, GraphQL, OpenAPI, ORM definitions, ANTLR-style grammars, WIT, WebIDL, and so on. For every such schema, someone has to turn it into idiomatic Wado source code. Today, we already have two one-off IDL-to-Wado pipelines living in the repository:

Each pipeline is wired up by hand in mise.toml, runs out-of-band, and produces output that is committed to the repository. There is no shared contract, no cache, no first-class story for users who want to do the same thing in their own projects. A user who wants to generate Wado from their own .proto files today has no better option than writing an external script and calling wado compile afterwards.

This WEP introduces Kiln (Keyed IDL Lowering Notation), a first-class mechanism for schema-driven code generation — the Wado equivalent of Java annotation processors (APT/KSP), Roslyn source generators, Swift macros, or Dart's build_runner, narrowed to a single use case: IDL → Wado source. The name joins Wado's existing element-themed tools (Tide for WebIDL, Gale for grammars) as the "fire" in which raw schemas are fired into Wado source, and the expansion captures the four defining properties of the design:

Scope

In scope:

Out of scope:

Prior art (brief)

Languages with a serious IDL story have landed on one of four designs: in-compiler plugins (Rust proc_macro, Roslyn, KSP — fast but full host privilege), out-of-process sandboxed plugins (Swift macros, protoc plugins — safer, cacheable), arbitrary pre-build programs (build.rs, build_runner — flexible but unsupervised), and manual on-demand generation (go generate — output drifts). Wado's constraints point at the sandboxed-plugin design: generators are Wado programs compiled to Wasm components, which is exactly the execution model Wado already speaks. The only question is the shape of the plugin contract, which is the subject of this WEP.

What Wado already has that we build on

Decision

Design principles

Four principles drive every detail in this WEP:

  1. Determinism by construction. The kiln world provides no access to clocks, randomness, the network, environment variables, or ambient filesystems. A generator is a pure function (inputs, options) → outputs + diagnostics. Violating this requires rebuilding the compiler; it is not a runtime flag. This is the one thing in-compiler macro systems (Rust, Roslyn) cannot cleanly guarantee.
  2. Every input is statically enumerable. No list-dir host function, no globs in the manifest. Every file that feeds a generator appears as a literal path in wado.toml or at a use ... with site. This lets the LSP jump to both the IDL and the generated .wado, and lets the compiler precompute the full dependency graph before any generator runs.
  3. Single-file scripts are first-class. Any generator configuration expressible in wado.toml works inline at a use site with the same schema and the same behavior, so a single .wado file with a shebang never needs a wado.toml just to consume a .proto.
  4. Runtime-free compiler core. wado-compiler never links a Wasm runtime. Generator component execution is a CompilerHost capability, on the same I/O boundary as source loading (wep-2026-01-16-source-provider-abstraction.md). Native hosts (wado-cli) execute generators via wasmtime; wasm32-bundled hosts (wado-lsp embedded in VS Code Wasm or the browser playground — see wep-2026-04-18-lsp-architecture.md) either forward execution through their own Wasm host or fall back to consume-only mode.

Overview

A generator is a Wado module that exports the core:kiln/generator world. wado-compiler compiles it to a wasm32-wasip3 component and hands the component bytes to the host via a new CompilerHost::run_generator capability; the host instantiates and invokes the component, and wado-compiler persists the returned response record — a set of .wado files and optional diagnostics — through the same CompilerHost. Content-addressed caching keyed on schemas, options, and the generator's source hash ensures unchanged inputs skip the run. Invocations form a DAG (one generator may consume another's output); cycles are a compile error. The responsibility split is spelled out in "Host-delegated execution" and the full sequence is specified in "Build pipeline integration" below.

The kiln world

Superseded in part by "Protocol revision 3": raw-request and the opaque options: list<u8> blob are retired — options are a typed generate argument in each generator's own world. kiln-host, input-file, response, and error are unchanged and stay shared.

The contract between the compiler and a generator is a single WIT world, core:kiln/generator, provisionally sketched as follows. The final WIT lives alongside the other Wado-authored worlds and is regenerated from Wado source via wep-2026-01-29-wit-wado-mapping.md once that pipeline exists.

package core:kiln;

interface kiln-host {
    /// Read a file from the compiler's CompilerHost.
    /// `path` is resolved relative to the invocation's declaration site
    /// (the manifest directory, or the source file containing a `use ... with`).
    /// All calls are recorded by the compiler and contribute to the cache key.
    /// The content is returned as UTF-8 text; binary schemas are out of scope.
    read-file: func(path: string) -> result<string, host-error>;

    /// Report a diagnostic that will be surfaced as a normal compile
    /// diagnostic. Multiple diagnostics may be reported; they are all
    /// printed even if the generator eventually returns a successful response.
    emit-diagnostic: func(diagnostic: diagnostic);

    variant host-error {
        not-found,
        permission-denied,
        io(string),
    }

    record diagnostic {
        level: diagnostic-level,
        span: option<source-span>,
        message: string,
    }

    enum diagnostic-level { error, warning, info, hint }

    /// A span into any file the generator has read, or into a file
    /// path that the generator knows about by other means. The compiler
    /// renders the snippet by consulting CompilerHost.
    record source-span {
        path: string,
        byte-start: u32,
        byte-end: u32,
    }
}

interface types {
    /// One schema file the compiler has pre-loaded for the generator.
    /// Kiln schemas are always text (proto, graphql, g4, wit, ...),
    /// so `content` is a `string` rather than `list<u8>`.
    record input-file {
        path: string,
        content: string,
    }

    /// The raw CM-level request record. Generator-side code sees
    /// `Request<Options>` (a Wado-only generic defined in `core:kiln`)
    /// whose `options: Options` field is populated from
    /// `raw-request.options` by a compiler-synthesized decoder.
    /// `raw-request` is an internal wire-form type and should not
    /// appear in generator source.
    ///
    /// `options` is a deterministic CBOR encoding of the user's
    /// `options = { ... }` block (see "Protocol revision 2"). The
    /// compiler uses the generator's typed `Options` descriptor (see
    /// "Options schema") to encode on the caller side; the generator
    /// decodes it with `core:cbor::from_bytes`. The same bytes feed the
    /// invocation cache key directly.
    record raw-request {
        primary: input-file,
        inputs: list<input-file>,
        options: list<u8>,
    }

    /// One generated Wado file. `path` is relative to the invocation's
    /// output directory (defaults to `build/kiln/<synthesized-id>/`).
    /// Exactly one output file must be the entry module; supplementary
    /// files are referenced from the entry via ordinary Wado `use`.
    record output-file {
        path: string,
        content: string,
        is-entry: bool,
    }

    record response {
        files: list<output-file>,
    }

    variant error {
        invalid-schema(string),
        unsupported(string),
        other(string),
    }
}

world generator {
    import kiln-host;
    use types.{raw-request, response, error};

    export generate: func(req: raw-request) -> result<response, error>;
}

Notes on the shape:

Options schema

A generator declares its options as an ordinary Wado struct named Options, exported from its entry file (the file named by generator = "..." in [package]). An ill-typed Options is a compile error at the point the generator is registered, not at the point it runs. Options is optional: a generator that takes no configuration omits the struct entirely (see "Protocol revision 2"), in which case any options supplied at a use site is an unknown-field error and the wire payload is an empty CBOR map.

The compiler extracts the Options shape from the generator's Wado IR when the generator package is first loaded, and uses it to type-check the options = { ... } block wherever the generator is invoked (manifest or inline use site). Because validation runs before the generator runs, typos and type mismatches show up at manifest parse time, and the LSP can offer completions for both wado.toml and inline with { generator: { options: { ... } } } blocks.

// In the generator module: example:proto-codegen's src/generator.wado

use { RawRequest, Request, Response, Error, bind_request } from "core:kiln";

pub struct Options {
    style: Style,
    emit_comments: bool,
}

pub enum Style {
    Rpc,
    Grpc,
    JsonRpc,
}

export fn generate(raw: RawRequest) -> Result<Response, Error> {
    let req = bind_request::<Options>(raw)?;
    // ... parse `req.primary.content`, emit Wado, honoring `req.options` ...
}

Default-value semantics follow the struct-defaults design (see wep-2026-03-04-default-trait.md): omitting the entire options block is equivalent to Options::default(), and any field that declares a default may be omitted individually. Fields without a default must be supplied.

Wire format. The WIT request carries options as a CBOR blob (list<u8>). At the call site, the compiler encodes the validated user-supplied options into a deterministic byte form that the cache key hashes directly. The generator author calls bind_request::<Options>(raw)? at the top of generate to decode that payload into a typed Request<Options> via the compiler-synthesized Options::deserialize; the Request<Options> adapter phase (below) lets authors write fn generate(req: Request<Options>) directly. Because the encoding is fully determined by the user's options and the descriptor, the byte sequence is stable across runs and feeds the invocation cache key directly.

See "Protocol revision 2" for why the options blob is CBOR rather than a typed WIT record, and why Request<T> stays a Wado-only generic.

Options introspection: describe-options (registry generators)

Retired by "Protocol revision 3". With options as a typed WIT argument, a prebuilt generator's options shape is read directly from its component WIT (the generate signature), so no describe-options introspection export is needed. This section is kept for history.

The compiler extracts the Options shape from the generator's Wado IR — which works only when it has the generator's source. A generator consumed as a prebuilt component (published to an OCI registry, module: "ns:name@ver") has no source at the consumption site, and options cross the CM boundary as an opaque CBOR blob, so the schema is not recoverable from the component's WIT either. A prebuilt generator must therefore carry its own options schema.

Decision: the core:kiln/generator world gains a second export,

describe-options: func() -> list<u8>;

returning the generator's options schema as a JSON Schema (Draft 2020-12 subset), CBOR-encoded. The shape is JSON Schema (a standard every host already understands); the wire encoding is CBOR — the same codec options values use, so schema and values share one format. This keeps the world uniform (one core:kiln/generator for every generator, options as opaque CBOR) while making the generator self-describing, so a non-Wado host can drive it too: instantiate → describe-options → validate/encode the user's options → generate. Exposing the schema (rather than validating inside the generator) preserves host-side, use-site diagnostics for unknown or ill-typed options.

The schema shape is Jade's Schema document model — capability A, implemented minimally in package-jade. Jade's type→schema derivation (capability B) waits on Reflect; until then the compiler maps the source-extracted OptionsDescriptor to a Jade Schema in Rust and synthesizes describe-options to return its pre-computed CBOR, and validates a consumer's options against a decoded Schema host-side. Options mapping follows Jade: a field with a default is optional with a JSON Schema default; a field without one lands in required; a no-payload enum becomes a string enum; additionalProperties is false. A generator whose options are { format: string, trace: bool = false } describes as:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "format": { "type": "string" },
    "trace":  { "type": "boolean", "default": false }
  },
  "required": ["format"],
  "additionalProperties": false
}

Adding describe-options is a core:kiln/generator world change: existing published generators (gale 0.0.x) predate it and must be republished under the new world before they can be consumed as registry generators. The end-to-end plumbing (fetch the generator at its world sub-path, run a prebuilt generator component, GeneratorModule::Spec resolution) is tracked in the dependency-management plan.

Host-delegated execution

wado-compiler must compile for wasm32-unknown-unknown: wado-lsp embeds it as a wasm32-wasip2 component for VS Code's Wasm host and the browser playground (wep-2026-04-18-lsp-architecture.md), and the compiler's Cargo manifest lists wasmtime only under [dev-dependencies]. Linking a Wasm runtime into the compiler core would break both constraints. Kiln therefore reuses the I/O boundary established by wep-2026-01-16-source-provider-abstraction.md and adds one method to CompilerHost:

pub trait CompilerHost {
    // existing methods: load_source, emit_diagnostic, ...

    /// Execute a generator component and return its response.
    ///
    /// `component_wasm` is the component the compiler produced from the
    /// generator module's Wado source. `request` mirrors the
    /// `core:kiln/types` records one-to-one. The host is responsible for
    /// instantiating the component, linking `core:kiln/kiln-host` so that
    /// `read-file` and `emit-diagnostic` forward back into this same
    /// `CompilerHost`, refusing every other import, and lifting/lowering
    /// Component Model values at the boundary.
    async fn run_generator(
        &mut self,
        component_wasm: &[u8],
        request: GeneratorRequest,
    ) -> Result<GeneratorResponse, GeneratorRunnerError>;
}

pub enum GeneratorRunnerError {
    /// This host does not implement generator execution.
    /// The compiler falls back to consume-only mode (see below).
    Unsupported,
    /// The generator ran and returned its typed `error` variant.
    Generator(GeneratorError),
    /// The generator trapped, timed out, exhausted fuel, etc.
    Host(String),
}

GeneratorRequest and GeneratorResponse are plain Rust types in wado-compiler that correspond 1:1 to the core:kiln/types records. They do not reference wasmtime; a host implementation that happens to use wasmtime lifts/lowers them at its own boundary via wasmtime::component::bindgen!.

Responsibility split:

Step Owner Notes
Parse [build-dependencies] in wado.toml wado-manifest Already wasm32-unknown-unknown-ready.
Read and write [[build-dependency]] in wado.lock wado-manifest Pure parsing + serialization.
Read and write per-invocation <primary>.kiln.json next to outputs wado-cli JSON; committable when output_dir is tracked.
Collect inline with clauses, deduplicate, build the DAG, topologically sort wado-compiler Pure logic.
Compile each generator's Wado source to a wasm32-wasip3 component wado-compiler Ordinary codegen path.
Compute cache keys wado-compiler Pure logic.
Instantiate the component, run generate, lift/lower CM values CompilerHost::run_generator Native host uses wasmtime; web hosts see below.
Persist output-file entries under <output-dir> CompilerHost Same filesystem view as load_source.
Re-parse generated .wado via the ordinary load_source path wado-compiler Generated files are indistinguishable from user source here.

The default native host (CliCompilerHost in wado-cli) wires run_generator to a wasmtime::component::Linker generated from the core:kiln/generator world. wado-compiler never sees a wasmtime type.

Transitional consume-only mode

In principle, any host that can run wado-compiler as Wasm can also run a generator component — it already has a Wasm runtime. In practice, today's non-wasmtime hosts (@vscode/wasm-wasi-core, browser polyfills for the playground) target WASI preview 1/2 and do not support nested Component Model instances, async, or WASI p3 — precisely the shape Wado generators require. Until those hosts catch up, the wasm32-bundled LSP and playground run in consume-only mode.

Consume-only semantics:

Consume-only is a compatibility bridge, not part of the steady-state design. Once a web host gains CM + WASI p3 support, the same CompilerHost implementation can route run_generator through that host's runtime and consume-only never kicks in. Nothing user-visible changes at that point.

Because wado-lsp running in web editors depends on this mode, projects that want a full web LSP experience set output_dir to a tracked path (e.g. src/generated/) and commit the generated .wado source together with the <primary>.kiln.json cache file that lives next to it; consume-only mode reads the committed cache file when available and falls back to "no cache, trust on-disk source" otherwise.

Consume-only is the floor, not the ceiling: a host with a Wasm runtime runs the generators directly. Native wado query (and the MCP / wado lsp backends that share its host) does exactly that. Rather than parsing the entry twice, the CLI runs the same pipeline wado compile uses (run_pipeline, wasmtime-backed CompilerHost::run_generator, outputs written to output_dir cache-aware) to build the redirect InvocationIndex, then hands it to the language engine via Engine::open_document_with_invocations. The engine uses an injected index verbatim and skips consume-only discovery; without one it falls back to consume-only. So a query on a generated symbol resolves even on a fresh tree with no prior wado compile. On any pipeline failure the query degrades to consume-only rather than aborting. This covers both position/diagnostics queries (run the pipeline against the queried file) and wado query --symbol (run it against the notation's target module, then re-key the redirect's decl_file from the target's path to the normalized module string the loader uses when the synthetic query entry imports it — see symbol_invocations in wado-cli/src/query_adapter.rs).

Anatomy of a generator module

A generator is a normal Wado package. It may be:

A generator package declares its entry file in its own wado.toml via the generator well-known-world field in [package], joining the existing command / service / lib fields. A single package may declare any subset of these, so the same package can be both a CLI and a Kiln generator:

[package]
name = "proto-codegen"
version = "1.2.0"
generator = "src/generator.wado"   # exports core:kiln/generator
command   = "src/cli.wado"         # optional: also a CLI (wasi:cli/command)

The compiler treats a package whose manifest contains generator = "<path>" as a Kiln generator. The referenced file must export pub fn generate(req: Request<Options>) -> Result<Response, Error>, enforced when the package is first loaded; pub struct Options is optional (a generator with no configuration writes fn generate(req: Request) — see "Protocol revision 2"). No special package kind is needed beyond the field itself — ordinary Wado source elsewhere in the package can be imported by the entry as usual.

// tools/my-codegen.wado — a local generator used only by this project.

use { Request, Response, OutputFile, Error } from "core:kiln";

pub struct Options {
    namespace: String,
}

export fn generate(req: Request<Options>) -> Result<Response, Error> {
    let text = String::from_utf8(req.primary.content)?;
    // ... parse text, produce Wado source ...
    return Result::Ok(Response {
        files: [OutputFile {
            path: `{req.options.namespace}.wado`,
            content: generated_source,
            is_entry: true,
        }],
    });
}

The generator is compiled to a wasm32-wasip3 Component Model component at the moment the compiler first needs to invoke it (see "Build pipeline integration"). Execution goes through CompilerHost::run_generator; the host contract (instantiation, import linking, import refusal) is described in "Host-delegated execution".

Generator output layout

Every invocation must emit exactly one entry module (is_entry: true) and zero or more supplementary modules (is_entry: false). The entry is the Wado module that a use { ... } from "<from>" binds to. Supplementary modules are ordinary Wado source files alongside the entry; the entry uses them via normal use "./helper.wado" statements, and all of Wado's usual visibility and import rules apply between them. Kiln does not define a virtual cross-file namespace — once files are on disk, they are just Wado.

A generator that emits zero entries, two entries, or a supplementary module with no entry is a compile error reported against the generator's response.

build/kiln/ directory layout

build/kiln/ holds shared, content-addressed caches (compiled generator components and extracted descriptors). Per-invocation cache state lives next to the generated outputs in output_dir, not under build/kiln/.

build/kiln/
├── generators/                 # compiled generator components
│   └── <stable-id>.wasm
└── metadata/                   # extracted metadata per generator module
    └── <stable-id>.descriptor  # serialized OptionsDescriptor, etc.

<output_dir>/                   # default: build/kiln/<synthesized-id>
├── <primary>.kiln.json         # per-invocation cache state (key + outputs)
├── <entry>.wado                # generated source
└── <supplementary>.wado

Two recommended workflows:

Both workflows use the same code path; the only difference is whether the user commits output_dir's contents.

Manifest schema: [build-dependencies]

wado.toml carries exactly one Kiln-relevant section, [build-dependencies]. Generators are normal Wado packages built for wasm32-wasip3 that must not enter the consuming project's runtime dependency graph. [build-dependencies] (mirroring Cargo's) accepts the same Git, Registry, Path, Workspace source shapes as [dependencies], resolves in a separate build-only graph, and is locked in its own section of wado.lock.

[build-dependencies]
proto-codegen = { registry = "example", package = "proto-codegen", version = "1.2" }
gale          = { path = "../package-gale" }

There is no [build.generators.*] section. Every generator invocation is declared inline at a use site (see "Use site syntax" below). This is a deliberate choice — see "Alternatives considered" — that trades inline verbosity for explicit, self-documenting source: every .wado file states which generator processes which schema with which options, with no manifest-side fallback that could rewrite imports out of band.

Use site syntax

A use clause whose source is a non-.wado path must carry a with clause that fully specifies the generator invocation. Bare use { ... } from "./schema.<ext>" for a non-.wado source is a hard error (Code::KilnMissingWith).

#!/usr/bin/env wado run

use { User, Billing } from "./user.proto" with {
    generator: {
        module: "example:proto-codegen@1.2",
        options: { style: "rpc", emit_comments: true },
    },
};

When the primary schema implicitly depends on sibling files the generator cannot discover from the primary alone (a parser grammar assuming a matching lexer grammar, an OpenAPI spec with an external components file, …), list them under inputs inside the generator record:

use { RustParser } from "./Rust.g4" with {
    generator: {
        module: "wado:gale@0.1",
        inputs: ["./RustLexer.g4"],
    },
};

The shape of the with clause is fixed:

with {
    generator: {
        module: "<spec>",
        version?: "<semver-req>",
        registry?: "oci://<host>[/<prefix>]",
        options?: { ... },
        inputs?: ["<path>", ...],
        output_dir?: "<path>",
    },
}

Each field has a single type, so there is no string-or-record polymorphism to explain.

Field semantics:

De-duplication:

Build pipeline integration

Kiln sits between parsing and name resolution in the pipeline described by wep-2026-01-14-compiler-pipeline-refactoring.md. The sequence for a wado compile run is:

  1. Read wado.toml and resolve [build-dependencies] into the build-dependency graph. Generator modules themselves are compiled lazily, so inline use ... with clauses may pull in additional modules not named in the manifest.
  2. Parse every .wado source file in the project. Collect each use ... with clause as an Invocation { module, from, inputs, output_dir, options, decl_file }. use resolution is deferred to step 5.
  3. Build the generator dependency graph: an edge from invocation A to invocation B exists if any of A's inputs (primary or supplementary), or any read-file target recorded on a previous run of A, lies inside B's output_dir. Topologically sort. Cycles are a compile error.
  4. For each invocation in topological order: a. Compute the cache key. b. Read <output_dir>/<primary>.kiln.json if it exists, where <primary> is the basename of Invocation::from. If the recorded cache key matches the freshly computed one and every output file listed in metadata exists on disk, the invocation is up-to-date — but the on-disk hash is also compared to the metadata's recorded hash; a mismatch surfaces a Code::KilnGeneratedModified warning and compilation continues against the on-disk content (the user's hand-edit is honored). On a clean cache hit, continue to the next invocation. c. Otherwise, compile the generator module to a wasm32-wasip3 component if not already cached. This step reuses wado-compiler's ordinary codegen pipeline. d. Build a GeneratorRequest (primary + inputs + options) and call CompilerHost::run_generator (see "Host-delegated execution" for its contract). Each read-file the host relays back is appended to the invocation's dependency list for future cache-key computation. On Ok(response) continue with 4(e). On Err(Unsupported) the consume-only path in "Transitional consume-only mode" runs instead: 4(e) is skipped, a stale-cache warning is emitted if the on-disk outputs do not match the cache key, and compilation continues against whatever is in <output_dir>. On any other Err(...) the invocation fails with a hard compile error reported against its declaration. e. Persist each output-file to <output_dir>/<path>, stamping the #![generated(...)] header. If a previous file at the same path differed from the new content (e.g. the schema or generator changed since the user last touched it), surface a Code::KilnGeneratedRegenerated warning naming the file. Delete any existing #![generated] file in output_dir that was not produced by this run. Write the new cache state to <output_dir>/<primary>.kiln.json.
  5. Resolve every use statement. A use { ... } from "<schema>" with { ... } binds to its invocation's entry module; bare use { ... } from "<schema>" against a non-.wado schema is the Code::KilnMissingWith error unless another clause in the same file already registered an invocation for that from. All other use statements resolve normally.

Generated files go through the same parser and elaborator as user-authored Wado, so a generator emitting syntactically invalid Wado fails with a normal parse error against the on-disk file.

Loader redirect

Step 5 above — "a use { ... } from \"<schema>\" with { ... } binds to its invocation's entry module" — is loaded by a dedicated mechanism called the loader redirect. It is what lets a user import from a .g4 / .proto / .wit / ... file without ever spelling the generated .wado filename. This section specifies it in one place.

Why redirect exists

A use { Parser } from "./Calc.g4" clause mentions a schema file, not a Wado module. The schema is the input to a generator, and the module the user actually wants to import is whatever the generator emits (by convention a snake-cased .wado file under build/kiln/<synthesized-id>/). Three goals follow:

The mechanism is a name-resolution hook: at use time, the compiler checks whether the import target matches an inline with clause recorded in the same file and, if so, rewrites the resolved ModuleSource to point at the generator's entry module.

Data model

Two pieces of compiler-internal state drive the redirect:

InvocationIndex::redirect(decl_file, from) performs a single lookup keyed by (decl_file, from). There is no project-wide fallback (no DeclScope::Any); a manifest section that could implicitly redirect imports across the project does not exist by design.

Cross-module harvest and identity remap

A with clause may live in an imported module, not just the entry, so the CLI harvests inline invocations across the entry's transitive local .wado imports (collect_inline_invocations_for_entry_with_identities). The harvest keys each invocation by the module's full path (decl_site.module), but the loader presents a base-relative loader identity (./eval.wado) at resolve time. remap_index_decl_files rewrites the index's decl_file keys from the harvest key to that identity.

The loader identity is canonical — every import path to a file derives the same identity (see "Canonical module identity" in the module-loader WEP). The harvest derives loader identities through the same name::canonical_local_path, so the map is single-valued: a plain diamond and an ..-escape-and-reenter both collapse to one identity, and a back-reference to the entry folds onto EntryPoint. Without canonical identities (#1423) the escape case produced two spellings (./gen/p.wado vs ../src/gen/p.wado) and the redirect was lost on the path the first-reached spelling didn't cover.

As a defensive check, if two distinct harvest keys ever collapse onto the same (loader_identity, from) but redirect to different generated modules, the remap reports Code::KilnRedirectConflict (a compile error) instead of silently letting the last write win and dropping one redirect (two entries that agree on the target are a benign duplicate). With canonical identities this is unreachable through ordinary imports — distinct files have distinct identities — so it guards only against a future identity-scheme regression.

URI scheme

Kiln-produced URIs use the kiln: scheme with no authority: kiln:/abs/path/to/generated.wado. Not file://. The choice is forced by a single invariant elsewhere in the compiler: qualified names take the form {module_source}//{name}, using // as the boundary between module source and symbol name. A file:///abs/path/to/foo.wado URI contains its own // inside file://, which collides with the name separator and breaks every parser that splits on // (for example wir_build::types::sort_types_topologically). kiln:/abs/path has no internal //, so the boundary stays unambiguous.

The URI is RFC 3986–valid, so it round-trips through fluent_uri::UriRef — the loader strips the scheme with a single parse call when handing the path to CompilerHost::load_source. Keeping this in fluent_uri (rather than std::path) keeps wado-compiler compatible with wasm32-unknown-unknown, where std::fs is unavailable.

End-to-end example

Given:

// package-gale/tests/driver_calculator_test.wado
use calc from "./grammars/calculator.g4"
    with {
        generator: {
            module: "../src/generator.wado",
        },
    };

test "parse" {
    let r = calc::parse(&"1+2").unwrap();
}

the flow is:

  1. wado test parses the file, collects the inline use ... with clause into an Invocation whose decl_file is package-gale/tests/driver_calculator_test.wado.
  2. The Kiln pipeline compiles src/generator.wado to a component, runs it, and writes the generated module to build/kiln/<synthesized-id>/calculator.wado.
  3. The driver inserts into the invocation index: ("package-gale/tests/driver_calculator_test.wado", "grammars/calculator.g4") → "kiln:/<abs>/build/kiln/<synthesized-id>/calculator.wado".
  4. The loader's resolve_import (and name::resolve_import_with_invocations on the analyze side) consults the index first. The lookup hits, so ./grammars/calculator.g4 resolves to ModuleSource::Redirected { uri: "kiln:/…/calculator.wado" }.
  5. ModuleLoader::get_source strips the kiln: scheme and asks the host to load /…/calculator.wado. From this point on the module is indistinguishable from any other user-local Wado source — it goes through parse, analyze, annotate, TIR, WIR, codegen using the same machinery.
  6. A second use { ... } from "./grammars/calculator.g4" (no with) inside the same file resolves to the same Redirected URI, so the module is loaded once and shares identity. The same bare use in any other file is rejected with Code::KilnMissingWith.

Failure modes and diagnostics

Generated file header

The compiler prefixes every generated file with a #![generated(...)] attribute:

#![generated(
    by = "example:proto-codegen@1.2",
    sources = ["./schemas/user.proto"],
)]

#![generated] is extended to accept named arguments in a separate, small compiler change. Unknown keys are tolerated, so future additions (world = "...", schema-hash = "...", …) are backward compatible.

The attribute lets wado format and linters skip the file, lets the LSP offer "go to schema" independently of any with clause, flags the file as derived for human readers, and gates stale-output garbage collection inside output-dir.

Caching and the <primary>.kiln.json cache file

Each invocation's cache key is the SHA-256 of a canonical concatenation of:

The compiled generator .wasm is not part of the cache key: its content is determined by the generator source and the compiler version, both already captured elsewhere. Hashing the .wasm would force every generator to re-run on a compiler upgrade for no added safety.

The cache state is recorded per invocation in a JSON file at <output_dir>/<primary>.kiln.json, where <primary> is the basename of Invocation::from. It is never in wado.lock: the lockfile is dependency-pin-only ([[package]], [[build-dependency]]); per-invocation cache state lives outside it. When output_dir defaults to build/kiln/<synthesized-id>/, the cache file lives there and is gitignored along with the rest of build/kiln/. When output_dir is committed (e.g. src/generated/<feature>/), the cache file is committed alongside the generated .wado so a fresh checkout (or CI) hits the cache and skips the generator.

<primary>.kiln.json schema (snake_case, Rust-struct-shaped):

{
  "version": 1,
  "invocation": "kiln-b277d3c4e6c19928",
  "generator": "example:proto-codegen@1.2.3",
  "generator_source_hash": "sha256:...",
  "primary": { "path": "schemas/user.proto", "hash": "sha256:..." },
  "inputs": [
    { "path": "schemas/common.proto", "hash": "sha256:..." }
  ],
  "reads": [
    { "path": "schemas/shared.proto", "hash": "sha256:..." }
  ],
  "options_hash": "sha256:...",
  "outputs": [
    { "path": "build/kiln/kiln-.../user.wado",  "hash": "sha256:...", "entry": true },
    { "path": "build/kiln/kiln-.../types.wado", "hash": "sha256:..." }
  ]
}

Notes:

Cache check at compile time:

Situation Behavior
<primary>.kiln.json missing Cache miss. Run generator, write outputs and <primary>.kiln.json.
Cache key matches and on-disk hash matches metadata Cache hit. Skip generator.
Cache key matches but on-disk hash differs from metadata Cache hit (the file was hand-edited). Surface Code::KilnGeneratedModified warning, use the on-disk content as-is — the edit is honored.
Cache key differs Cache miss. Run generator. If the new output bytes differ from any pre-existing file, surface Code::KilnGeneratedRegenerated warning before overwriting.

Hand-edits are deliberately accommodated: temporarily tweaking a generated file to debug a downstream issue should not require fighting the build system. The warning makes the divergence visible without breaking the loop.

In consume-only mode (see "Transitional consume-only mode"), the compiler reads <output_dir>/<primary>.kiln.json to obtain the entry path and applies the same cache check. A mismatch produces a warning diagnostic, not an error; compilation proceeds against whatever is on disk and the cache file is not rewritten.

The wado check command

wado check is the CI-facing companion to wado compile. It runs the same pipeline up to (but not including) Wasm code generation, force-regenerates every Kiln invocation regardless of any <primary>.kiln.json, byte-compares each fresh output against the on-disk file, and treats every Kiln warning as an error.

wado check               # CI default: warnings-as-errors, codegen skipped
wado check --warn        # opt out of warnings-as-errors (rare, for local triage)
wado check path/to/main.wado  # check a single entry

Behavior:

The same cache-coherence verification runs implicitly during wado compile as well, but with warnings reported as warnings — wado check differs only in promoting them to errors and skipping codegen.

For projects that leave output_dir at the default build/kiln/<synthesized-id>/, there is no committed file to compare against, so wado check only verifies that every generator runs to completion. For committed-source projects, it is the gate that catches "I changed the schema or generator and forgot to re-commit the regenerated file".

Diagnostics: extending spans to external files

Today, wado-compiler spans reference an internal FileId assigned when a source file is loaded into a SourceMap. Kiln needs spans to point at IDL files that the compiler itself did not originally parse — it only read them on behalf of a generator via host.read-file. Two small extensions handle this:

  1. SourceMap registers any file that comes in via CompilerHost::load_source, including files read on behalf of a generator. Once a file has been read, it has a stable FileId for the rest of the compilation.
  2. The generator-facing source-span carries (path, byte-start, byte-end). When the compiler materializes a diagnostic from host.emit-diagnostic, it looks up (or registers) the FileId for path and renders in its normal format. If path was never read by this run, the diagnostic is displayed without a source excerpt and marked unverified.

Existing user-facing diagnostics are unaffected.

Relationship to existing pipelines

wado-from-idl stays a Tier 0 (bootstrap) tool: it generates the wasi:* standard library that Wado itself depends on, so authoring it in Wado would be a chicken-and-egg. It continues to run from mise run update-stdlib-wasi with committed output and is not migrated to Kiln.

Gale is Tier 1 (first-party but self-hostable) and migrates: an inline use ... with { generator: { module: "wado:gale", options: { ... } } } clause replaces the mise run update-gale-golden dance. Gale's module exports core:kiln/generator with an Options matching its current CLI flags.

Versioning

The kiln world is versioned as an ordinary WIT interface, starting at core:kiln@0.1.0. A generator declares the exact version it targets in its own wado.toml; a mismatch against the compiler's supported set is a hard error naming both sides. 0.x compatibility is not guaranteed; post-1.0 follows ordinary component-model versioning. The world version is part of the cache key, so bumping it invalidates every cached output automatically.

Protocol revision 2

Three coupled changes to the options protocol, landed together. They share one theme: options are a serialized blob by design, so make that blob good and stop pretending it will ever become a typed WIT record.

Options wire form: canonical JSON → CBOR

The raw-request.options field changes from string (canonical JSON) to list<u8> (deterministic CBOR, RFC 8949). bind_request decodes with core:cbor::from_bytes instead of core:json::from_string. Motivation:

The MAGIC cache-key prefix bumps to kiln-cache-key-v4, invalidating every prior cache entry. The world version stays 0.1.0 (pre-release, and the MAGIC bump already forces regeneration).

Options is optional, and Request defaults to NoOptions

Request gains a default type argument: pub struct Request<T = NoOptions>, where NoOptions is an empty core:kiln struct. A generator that takes no configuration omits pub struct Options entirely and writes the bare fn generate(req: Request) — no Options boilerplate, no RawRequest escape hatch. The Request<T> adapter phase recognizes the bare Request form and binds NoOptions; the descriptor extractor treats a missing Options as an empty descriptor rather than an error (generate is still required). At a use site, supplying any options field then fails as an unknown field, and the wire payload is an empty CBOR map (0xa0), which NoOptions::deserialize accepts. Use-site omission of the options block (whole block or individual defaulted fields → Options::default()) was already supported and is unchanged.

Request<T> stays a Wado-only generic — the typed-WIT-record plan is dropped

Reversed by "Protocol revision 3". The premise below — "a fixed WIT world cannot carry a per-generator record" — assumed a single shared world; dropping that assumption makes the per-generator typed options record reachable after all, as a generate argument. Request<T> survives as the author-facing sugar, but it is now assembled from typed arguments, not decoded from a CBOR blob.

The v1 design justified Request<T> as a temporary bridge to an eventual per-generator typed WIT record that would supersede the serialized-options layer. That end state is unreachable: a fixed WIT world cannot carry a generic or a per-generator record, so options must always cross the boundary as an opaque blob. This revision makes that permanent and re-justifies the generic on its own terms:

Protocol revision 3 — per-generator typed options (supersedes revision 2's opaque blob)

Revision 2 concluded that options must cross the boundary as an opaque CBOR list<u8> because "a fixed WIT world cannot carry a generic or a per-generator record." That fixed the wrong variable. The constraint was never fundamental — it followed from the self-imposed assumption that every generator conforms to one shared generate(raw-request) world. But a generator's options shape differs per generator anyway, so forcing them all through one signature buys nothing at the options level. The only parts that genuinely must be shared are kiln-host, input-file, response, and error — never options.

Decision: options cross the boundary as a typed WIT value, passed as a separate argument to generate in the generator's own world:

// core:kiln — shared types only. No `generate`, no `options`, no `raw-request`.
interface types { record input-file {…} record output-file {…} record response {…} variant error {…} }
interface kiln-host { … }

// each generator's own world, synthesized from its `Options` struct
world generator {
    import kiln-host;
    use types.{input-file, response, error};
    export generate: func(
        primary: input-file,
        inputs: list<input-file>,
        options: <options-record>,   // the generator's `Options`, lowered to WIT
    ) -> result<response, error>;
}

Retired by this revision: raw-request.options: list<u8>; the describe-options export and its JSON-Schema-in-CBOR encoder/decoder; the standalone OptionsDescriptor-as-schema round-trip; the generator-side CBOR options decode. The "kiln world" raw-request/generate sketch, the "Options introspection: describe-options" section, and the revision 2 "Request<T> stays a Wado-only generic" conclusion are all superseded by this section.

Consequences: options become "just another typed CM boundary," reusing the compiler's component-type and canonical-ABI machinery instead of a parallel options subsystem — simpler, more type-safe, and better for cross-language hosts (a typed options record in the WIT beats a CBOR JSON Schema blob). The cost is that the host moves from static bindgen to dynamic Val invocation (one generic path), and each generator emits its own world (mostly shared, differing only in the options argument).

Source vs registry options descriptor — accepted asymmetry

The "discovered uniformly as a CM type" claim holds for the options record type and for runtime lowering: run_generator always reads the options type from the resolved component's own generate signature and lowers the validated options against it, whichever kind of generator produced that component. From ResolvedGenerator onward — validation, generate invocation, response lift, output cache — source and registry generators share one path.

The two resolution front-ends are not symmetric, in exactly one respect: where the compile-time options descriptor comes from, and whether it carries defaults.

Consequence: a source-level default such as gale's trace: bool = false does not cross the registry boundary. A consumer of the published gale generator writes options = { trace: false } explicitly; a consumer of the same generator by local path may omit trace.

This asymmetry is accepted rather than closed. Closing it would require the component to carry its option defaults (a small defaults-side metadata next to the WIT), which re-introduces a mechanism this revision deliberately dropped when it retired describe-options. The runtime is already unified on "the component's WIT is the options truth"; only the compile-time descriptor's default information differs. If registry-generator defaults become worth the cost, encode them alongside the component and recover the full descriptor from it on both paths, so kiln_wit becomes the single descriptor source and the Wado-side extractor only emits the defaults metadata.

Milestones

Each milestone is independently reviewable, has its own tests, and keeps cargo check -p wado-compiler --target wasm32-unknown-unknown green.

M1 — Manifest + lockfile schema (done; later revised in M8 and M9)

wado-manifest carries [build-dependencies] and the lockfile grows [[build-dependency]] + [[generator-cache]], with round-trip fixtures and deterministic re-serialize. wasm32-unknown-unknown stays green. M9 (below) later removed [[generator-cache]] from wado.lock in favor of the per-invocation metadata.json file; wado-manifest retains only [[build-dependency]].

The original M1 also shipped a [build.generators.<name>] section that auto-routed bare use ... from "./schema.<ext>" clauses through manifest-declared invocations. M8 (below) removed that section and the bare-use auto-routing path, leaving inline with { ... } as the only way to declare a generator invocation.

M2 — CompilerHost::run_generator + native wasmtime impl (done)

CompilerHost::run_generator is defined in the compiler with a default Err(Unsupported) body; wado-cli::kiln_runtime implements the native wasmtime half, exposing only core:kiln/kiln-host (no WASI, no clocks, no http). The WIT source of truth lives at wado-compiler/lib/core/kiln/generator.wit.

M3 — Pipeline integration (done)

wado-compiler::kiln::{invocation, plan, cache, header} supply the compiler-internal half; wado-cli::kiln_driver supplies the host side (plan, execute, run_pipeline, cache bookkeeping, orphan GC). The GeneratorProvider trait decouples module → component-bytes resolution.

M4 — Typed Options extraction + validation (done)

wado-compiler::kiln::{options, options_check, cache::encode_options_canonical} extract, validate, and canonically encode generator options directly from the generator's Wado IR.

M5 — Inline use ... with { generator: ... } syntax (done)

parse_import_attributes + AttrValue + kiln::inline::collect_inline_invocations collect inline clauses; name::resolve_import_with_invocations threads the redirect through the loader / analyzer / elaborator / annotate / CompilerOptions.invocations chain. See §"Loader redirect" for the concept.

M6 — Rename, layout, stdlib, generator compilation, Gale migration

M6 is the milestone where a Wado-authored generator is intended to run through the native wado compile path. The seven subsections share the same user-visible surface (the core:kiln namespace, the [package].generator manifest field, the build/kiln/ layout). Stages 1–4 (M6.1–M6.4 plus the foundational half of M6.5) shipped on branch claude/plan-m6-implementation-*.

M6.1 — Rename wado:kilncore:kiln (done)

WIT package renamed to core:kiln@0.1.0, file moved to wado-compiler/lib/core/kiln/generator.wit, and every reference (WORLD_VERSION, include_str!, docs, errors, fixtures) updated in lockstep.

M6.2 — [package].generator well-known-world field (done)

wado-manifest::Package gains generator: Option<String> alongside command / service / lib; the transitional build_world field is removed. package-gale/wado.toml migrates to generator = "src/generator.wado" while keeping command = "src/main.wado" for the legacy CLI.

M6.3 — build/kiln/ layout unification (done)

CliGeneratorProvider writes compiled generators to build/kiln/generators/<stable-id>.wasm and reserves build/kiln/metadata/ for M6.6's OptionsDescriptor cache. Per-invocation output stays under build/kiln/<invocation-name>/.

M6.4 — core:kiln stdlib module (done)

WIT (interface kiln-host, record raw-request with options: string, read-file -> result<string, host-error>) drives wado-from-idl to emit lib/core/kiln/{kiln_host,types,worlds}.wado; the hand-written facade lib/core/kiln.wado re-exports the public surface and adds the Wado-only Request<T> + bind_request. Cache-key encoding switches to canonical JSON (sorted keys, literal UTF-8 string payloads, shortest-roundtrip numerics) and MAGIC bumps to kiln-cache-key-v3\0 (v2 was the M6.4 introduction with NFC normalization on string values; v3 removed it because TOML parsers and modern editors do not normalize, so the cache key now reflects the literal bytes the user typed). The same bytes feed hash_options_canonical and the wire options: string so cache and wire agree byte-for-byte.

M6.5 — Compiler support for core:kiln/generator well-known world (done)

core:kiln/generator is registered in WorldRegistry; Options::deserialize auto-synthesizes through bound-driven derivation (WEP 2026-06-25-trait-derivation): bind_request::<Options>'s T: Deserialize bound records the request like any other bound, so no marker injection or pre-serde_synth hook exists. A generator whose Options has a non-deserializable field fails that bound at the bind_request call with a reason chain naming the field, and the options-schema check (Phase 6c) reports its own diagnostic for generators that never bind. The full core:kiln type surface is registered in CmInterfaceRegistry via the two-pass stdlib bootstrap; CM lift / lower paths handle kiln records via LiftContext::cm_package and CmInterfaceRegistry::resolve_cm_source_for. Import-refusal (Code::KilnGeneratorForbiddenImport) blocks any non-kiln-allowlisted import in a generator-world build, with the M2 runtime check kept as defense in depth.

Lower-side CM emission: emit_kiln_world_types imports the full core:kiln/types surface as an InstanceType and aliases the exported types into the component-level type space, satisfying wasm-tools's all_valtypes_named_in_defined. emit_world_exports uses (param req: raw-request) -> result<response, error>; CanonicalIntrinsic::TaskReturn picks kiln-handler-result; generate is marked async func and kiln_runtime drives it through store.run_concurrent(...). skip_validation: false holds.

Author ergonomics: kiln::import_check::inject_kiln_request_adapter rewrites export fn generate(req: Request<T>) to the internal RawRequest + bind_request::<T> shape before analyze runs and auto-extends the use { ... } from "core:kiln" declaration, so authors never mention compiler-synthesized symbols. The v1 explicit fn generate(raw: RawRequest) form still compiles.

M6.6 — CliGeneratorProvider::get_component real implementation (done)

GeneratorModule::LocalPath(path) compiles the package to a wasm32-wasip3 component through the ordinary codegen path and caches it at build/kiln/generators/<stable-id>.wasm (stable-id = SHA-256 of "kiln-generator-v1\n" || normalized-path || "\n" || source-bytes, 16 hex chars). GeneratorModule::Spec(_) returns Unsupported. descriptor() extracts OptionsDescriptor from the same compile and caches to build/kiln/metadata/<stable-id>.descriptor; compile_local is shared between both entry points so the first call warms both caches.

M6.7 — Gale Kiln-shape generator (done)

package-gale/src/generator.wado exports fn generate(req: Request<Options>) -> Result<Response, Error> (v2 ergonomic form, rewritten by the Request<T> adapter phase) and delegates to codegen.wado. Options lives inline. The export fn run() CLI stays in package-gale/src/main.wado for ad-hoc use and is exercised by an end-to-end CLI test; the mise.toml's update-gale-golden task and the tests/golden/ fixtures it produced have been retired now that every in-tree consumer drives Gale through Kiln. Coverage: wado-cli/tests/kiln_compile.rs (synthetic single-file generator, first-run + cache-hit), wado-cli/tests/kiln_gale_path.rs (manifest plan + CliGeneratorProvider::get_component end-to-end against Gale), and all of package-gale/tests/driver_*_test.wado (including SQLite) drive the generator live via inline Kiln.

M6 sequencing

M6 shipped across four branches; each item below is the high-level slice as it landed. Refer to git log on the named branch for the full commit history.

claude/plan-m6-implementation-* (M6.1–M6.4 + M6.5 stage 1 + collision-detection regression fixtures): pure refactor (rename, manifest field, layout) followed by core:kiln stdlib generation, world registration, comp_feature scaffolding, and kiln_synth pre-serde pass.

claude/kiln-task-review-08iET: CmInterfaceRegistry source-awareness — NamedType.source_interface populated by a two-pass stdlib bootstrap; every CM-binding caller migrated to *_by_source(interface, name) lookups; cross-namespace collisions (e.g. core:kiln/types::Response vs wasi:http/types::Response) become impossible.

claude/complete-m6-kiln-fix-TQVxu: M6.5 stage 2 v1 (explicit bind_request), M6.6 (real CliGeneratorProvider::get_component), M6.7 (Gale Kiln-shape generate), CM adapter lift path (LiftContext threading + resolve_cm_source_for), and self-review cleanups (consistent cm_package naming, LiftContext/FlatLiftContext collapse, param_needs_lifting simplified, dense AstId allocation).

claude/complete-m6-milestone-qbxNA: end-to-end CM closure plus first-class designs.

M6 is complete. Remaining follow-ups (independently reviewable; each keeps cargo check -p wado-compiler --target wasm32-unknown-unknown green):

M7 — Consume-only mode (done)

Compiler-side: run_pipeline compares the cache key before invoking the runner; on Unsupported (from runner or provider) with cache match, silently passes; on cache miss, emits Code::KilnStaleCache and writes nothing to <output-dir> / metadata.json. Three wado-cli tests cover the three branches.

LSP-side (wado-lsp::kiln::prepare_invocations): Engine::{diagnostics,definition,hover,references,document_highlight} parse the entry source, locate the nearest wado.toml, run collect_inline_invocations, and for every invocation either (a) read <output_dir>/<primary>.kiln.json, and if it records an entry output that still exists on disk within the workspace, register (decl_file, from) → kiln:/abs/path in the resulting InvocationIndex, or (b) emit Code::KilnStaleCache with the use clause's span when there is no usable artifact (no metadata, no entry recorded, or the output is missing / escapes the workspace). It does not validate the recorded hashes — a consume-only host cannot regenerate, so trusting the artifact is the only behavior that keeps queries working (see §"Transitional consume-only mode"). The index is threaded through wado_compiler::load (stage 2 of the parseloadsemantics_of composition). Consume-only mode is read-only: no metadata writes, no output writes. Coverage in wado-lsp/tests/kiln_consume_only.rs (artifact-present-redirects, drift-still-redirects, no-usable-artifact-warns, no-write-back, traversal/symlink sandboxing) and wado-cli/tests/lsp.rs (wado query resolves a generated symbol after wado compile, warns when absent). The schema types live in wado_compiler::kiln::metadata so the CLI driver's wado-cli/src/kiln_metadata.rs and the LSP's wado-lsp/src/kiln.rs share Metadata / FileHash / OutputEntry / METADATA_VERSION without depending on each other's I/O.

M8 — Explicit-only invocation surface

Drop the manifest-side invocation registry and the bare-use auto-routing path. After M8 every generator invocation is declared inline at a use site; wado.toml retains only [build-dependencies] for the build-time generator graph. The change is a hard break — Wado is pre-release and there are no compatibility shims.

M9 — Per-invocation metadata.json, wado check subcommand

Move generator-cache state out of wado.lock and into per-invocation build/kiln/<id>/metadata.json files. The committed lockfile becomes dependency-pin-only. Add a new wado check CLI subcommand that re-runs every invocation and byte-compares against on-disk source, with warnings promoted to errors by default.

The motivation is that recording outputs[].hash in a committed file forced every change to a generator's source to ripple into every consumer's wado.lock, even when the schema and options were untouched. Each parser-gen tweak in package-gale produced lockfile churn in benchmark/sqlite_parse, benchmark/syntax_highlight, and wasm-size/sqlite_highlight, with the surrounding commits forced to either include all three regenerated lockfiles or ship a follow-up "regenerate stale wado.lock" chore. Moving the output hash into a gitignored sidecar removes the committed surface that was churning, keeps tamper-/edit-detection (the file's SHA-256 still rides next to it locally), and clarifies that wado.lock is for dependency pinning while metadata.json is for incremental rebuild.

Alternatives considered

In-compiler macro system

Running generator code directly inside wado-compiler (the Rust proc-macro model) would be faster and drop the wasmtime dependency, but it exposes the full host environment to every generator (forfeiting determinism), and generators written in Wado could not participate without a second, separate mechanism. Reusing Wasm sandboxing — already integral to Wado — keeps the plugin surface consistent with the rest of the language.

The shortest path to a working Kiln prototype is to add wasmtime to wado-compiler's main dependencies and drive it from inside the pipeline. This was rejected on two independent grounds that Wado's existing architecture makes non-negotiable:

  1. wado-compiler must build for wasm32-unknown-unknown — enforced by CI (.github/workflows/ci.yml: check-wasm32) and required because wado-lsp bundles the compiler as a wasm32-wasip2 component for VS Code Wasm and the browser playground (wep-2026-04-18-lsp-architecture.md). wasmtime does not build for wasm32-unknown-unknown.
  2. The same I/O boundary that already keeps source loading out of wado-compiler (wep-2026-01-16-source-provider-abstraction.md) applies equally to generator execution; a one-off escape hatch for Kiln would be out of family.

Delegating execution to CompilerHost::run_generator gives the native build the same wasmtime-backed behavior at no cost, while letting wasm32 clients degrade to consume-only until their hosts can run the generator themselves.

Pure-Rust Wasm interpreter in the compiler

Embedding wasmi or tinywasm would keep the compiler self-contained on wasm32-unknown-unknown, but neither currently supports the Component Model, async, or WASI p3 — the full shape Wado generators expect. This alternative only becomes viable if a suitable pure-Rust CM + p3 host appears. Until then, consume-only mode is the cheaper transitional answer.

Out-of-band generation before wado compile

Exposing Kiln as a separate wado kiln generate step that users must run before compiling would remove every runtime concern from wado-compiler, but it breaks principle 1 of the Overview ("Invocation is automatic") and kills the single-file-with-shebang path. Host delegation preserves automatic invocation without pulling a Wasm runtime into the compiler core.

Pre-build arbitrary program (build.rs equivalent)

Declaring an arbitrary pre-compile command in wado.toml is maximally flexible but unsupervised. Users who need this can run their own shell before wado compile; no language support is required.

Static enumeration: no globs, no list-dir

Both inputs = ["schemas/**/*.proto"] globs and a host.list-dir import were rejected for the same reason: they hide the generator's input set from the LSP and from static tooling, and introduce an ambient "a file appeared in the directory" failure mode. The explicit list of paths costs one line per schema and is paid once per project.

Manifest-side invocation registry ([build.generators.<name>])

The original M1 design declared invocations in wado.toml and let bare use ... from "./schema.<ext>" clauses pick them up by matching from. M8 dropped that path. The two failure modes the manifest form created outweighed its boilerplate-reduction benefit:

Inline-only restores both invariants: every .wado file states which generator processes which schema with which options, and the redirect index is keyed by the declaring file. The cost is that projects with many schemas (e.g. 50 .proto files all using the same generator and options) write the same with { generator: { module: ..., options: ... } } block on each use. We accepted that — Wado is pre-release, the consuming surface (Gale today, gRPC / OpenAPI / GraphQL later) is small, and any future de-duplication mechanism can be added on top of inline without breaking call-site explicitness.

Named-template, options-merge, and extension-keyed manifest variants were considered along the way and rejected for reintroducing the same "spooky action at a distance" in a slightly different shape.

Opaque JSON/TOML options

Accepting options as an untyped blob would defer validation to generator runtime, where errors are necessarily worse than the compiler's. A typed Options record also enables LSP completions at use sites.

Open questions

The following are deliberately unresolved here; they will be decided during implementation or in follow-up WEPs.

Consequences

References