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:
wado-from-idlgenerates thewasi:*standard library from WIT and Web APIs from WebIDL. Seewep-2026-04-01-tide.md.- Gale (
package-gale) generates Wado parsers from ANTLR.g4grammars. Seewep-2026-03-02-gale.md.
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:
- Keyed — every invocation is content-addressed by a cache key covering schemas, options, and the generator's own source hash (see "Caching and the
<primary>.kiln.jsoncache file"). - IDL — the sole accepted input shape is an interface description language; Kiln is deliberately narrower than a general build system.
- Lowering — Kiln lowers high-level schema files into plain Wado source that the ordinary compiler pipeline then handles with no further specialization.
- Notation — the user-facing surface is a declarative notation (
use ... from "<schema>" with { generator: { ... } }), not an imperative build script.
Scope
In scope:
- Users register code generators in
wado.tomlor inline at ausesite. - Generators read schema files (
.proto,.graphql,.g4,.wit,.json, custom IDLs, …) and emit.wadosource files. - Generators are ordinary Wado packages compiled to
wasm32-wasip3Component Model components bywado-compilerand executed by the host via a newCompilerHost::run_generatorcapability. The native host (wado-cli) uses wasmtime;wasm32-bundled clients either forward to a Wasm runtime available to them or fall back to the consume-only mode described in "Transitional consume-only mode". - Shared Kiln caches live under
build/kiln/: compiled generator components underbuild/kiln/generators/and extracted descriptors (e.g.OptionsDescriptor) underbuild/kiln/metadata/.build/kiln/itself is intended to be.gitignored; cache state is rebuilt on demand and is never required for compilation correctness. Per-invocation cache state — the cache key + output hashes — lives next to the outputs as<output_dir>/<primary>.kiln.json, so the committed-source workflow can commit the cache file alongside the generated.wadoand a fresh checkout hits the cache. Generated.wadosource defaults tobuild/kiln/<synthesized-id>/but may be redirected per-invocation viaoutput_dirto a committable location such assrc/generated/. See "Caching and the<primary>.kiln.jsoncache file" and "Transitional consume-only mode" for the two workflows. - Invocation is automatic: the compiler runs the relevant generators during
wado compile, with content-addressed caching so unchanged inputs skip the generator.
Out of scope:
- In-compiler token-level macros, à la Rust
proc_macroor Scala 3 quoted macros. - Type-driven derive (e.g.,
#[derive(Eq)]). Wado already auto-derivesEqandOrdwhen all fields satisfy the trait; richer type-driven codegen is a separate concern. - Arbitrary pre-build scripts, à la
build.rs. This WEP does not expose "run this program of my choice before compile"; all code generation goes through thekilnworld and its sandbox. - Compile-time file inclusion. That is handled by
wep-2026-03-02-include-str.md.
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
CompilerHostabstraction (wep-2026-01-16-source-provider-abstraction.md) —wado-compileris itself I/O-free. All source reads go through a trait implemented bywado-cli. Kiln reuses this boundary: generators never touch the filesystem directly; they call a host-providedread-fileimport that the compiler forwards toCompilerHost.wado.toml+wado.lock(wep-2026-02-14-package-manifest.md) — package manifest and lock file. We add a new[build-dependencies]section for build-only generator packages.wado.lockitself is dependency-pin-only; per-invocation cache state lives outside the lockfile (see "Caching and the<primary>.kiln.jsoncache file").- Wado → WIT mapping (
wep-2026-01-29-wit-wado-mapping.md) — the longer-term mechanism for translating Wado types into WIT. Kiln relies on it only at its eventual evolution point (per-generatorOptionsWIT records, see "Options schema"); the v1 path extracts theOptionsshape directly from the generator's Wado IR. #![generated]module attribute — already parsed by the compiler. Kiln extends it with named arguments; see "Generated file header".
Decision
Design principles
Four principles drive every detail in this WEP:
- Determinism by construction. The
kilnworld 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. - Every input is statically enumerable. No
list-dirhost function, no globs in the manifest. Every file that feeds a generator appears as a literal path inwado.tomlor at ause ... withsite. 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. - Single-file scripts are first-class. Any generator configuration expressible in
wado.tomlworks inline at ausesite with the same schema and the same behavior, so a single.wadofile with a shebang never needs awado.tomljust to consume a.proto. - Runtime-free compiler core.
wado-compilernever links a Wasm runtime. Generator component execution is aCompilerHostcapability, 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-lspembedded in VS Code Wasm or the browser playground — seewep-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-requestand the opaqueoptions: list<u8>blob are retired — options are a typedgenerateargument in each generator's own world.kiln-host,input-file,response, anderrorare 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:
- There is no
write-fileimport. All outputs are returned in theresponserecord. This keeps the contract a pure function of its inputs and makes caching a straight hash comparison. - There is no
list-dir, nowasi:clocks, nowasi:random, nowasi:http, nowasi:sockets, and nowasi:cli/environment. A generator that wants non-determinism has to work for it, and to work for it it would have to export a different world, which the compiler refuses to load. read-fileexists even thoughrequest.primaryandrequest.inputsalready carry the declared file set, because real schemas have transitive references (.protofilesimportother.protofiles, GraphQL SDL hasextend type, WIT hasusestatements). The generator callsread-filefor transitive pickups; every such call is logged and contributes to the cache key.source-spanis(path, byte-start, byte-end). The compiler renders the snippet by re-reading the file viaCompilerHost. This is a small extension to Wado's existing diagnostic system — today spans reference internal file ids, and Kiln needs spans to point at files the compiler itself did not load directly. See "Diagnostics" below.optionstravels as a CBOR blob (list<u8>) at the WIT boundary but is typed per-generator on the Wado side: the compiler validates the user-written options against the generator'sOptionsstruct, encodes them to deterministic CBOR on the caller side, and synthesizes an adapter that decodes the bytes into that typed struct before user code runs. Generator authors never see the blob. See the next section and "Protocol revision 2".
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
generatesignature), so nodescribe-optionsintrospection 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:
run_generatorreturnsUnsupported.- A consume-only host cannot run the generator, so it cannot re-derive any of the hashes
<output_dir>/<primary>.kiln.jsonrecords. Validating them would only make the redirect silently die and break every query over a generated symbol, so consume-only trusts the on-disk artifact rather than verifying freshness:- Artifact present: the metadata records an entry output that still exists on disk (and stays within the workspace). The generated
.wadois loaded through the ordinaryload_sourcepath; compilation proceeds as if the user had authored it by hand — regardless of schema / options / generator / reads / output-byte drift since generation. - No usable artifact: no metadata, no entry output recorded, or the recorded output is missing or escapes the workspace. The compiler emits a
warningdiagnostic (no generated output found: <invocation>; re-run 'wado compile' natively to generate it) and treats the invocation's entry module as present-but-unresolved.usestatements targeting its symbols surface as ordinary resolution errors; the rest of the compilation unit still proceeds.
- Artifact present: the metadata records an entry output that still exists on disk (and stays within the workspace). The generated
- No writes occur: neither
<primary>.kiln.jsonnor<output_dir>is modified in consume-only mode. - Freshness — whether the committed generated
.wadostill matches what the generator would produce now — iswado check's job (native, runtime-backed). Consume-only intentionally does not duplicate that check, since it cannot run the generator to answer it.
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 standalone package distributed via the registry, a git URL, or a path;
- a subdirectory of the consuming project (for project-local generators); or
- a single
.wadofile inside the consuming project.
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
build/kiln/generators/<stable-id>.wasm— thewasm32-wasip3Component Model component that the compiler produced from a generator package's Wado source.<stable-id>is content-addressed: forGeneratorModule::LocalPath, the normalized path plus source tree hash; forGeneratorModule::Spec, the resolvednamespace:name@versionplus source-distribution hash. The compiled.wasmis not part of the cache key for an invocation (see "Caching and the<primary>.kiln.jsoncache file") — it is a cache of the generator compilation itself, keyed independently.build/kiln/metadata/<stable-id>.descriptor— deserializable cache of per-generator-module metadata the compiler extracts when it first loads a generator, currently the typedOptionsDescriptor. Regenerated whenever the corresponding.wasmis regenerated. (Distinct from per-invocation<primary>.kiln.json.)<output_dir>/<primary>.kiln.json— per-invocation cache state: the cache-key inputs from the previous run plus the path and content hash of every output it produced. Read by the compiler to decide whether to rerun the generator and to detect hand-edits of generated files. The basename is thefile_nameofInvocation::from(the primary input). Living next to its outputs lets the committed-source workflow keep the cache state under git, so a fresh checkout (or CI) hits the cache and skips the generator on the first compile. See "Caching and the<primary>.kiln.jsoncache file" for the schema.<output_dir>/<entry>.wado(and supplementary modules) — generated.wadosource for the invocation. The defaultoutput_dirisbuild/kiln/<synthesized-id>/;output_dirredirects it to any project-relative path such assrc/generated/. Files carry the#![generated(...)]header (see "Generated file header") and are managed by the compiler.
Two recommended workflows:
- Ephemeral (default): leave
output_dirat its default. Generated.wadoand the<primary>.kiln.jsoncache file live underbuild/kiln/<synthesized-id>/; the wholebuild/kiln/is.gitignored, and a clean checkout regenerates everything on first compile. - Committed source: set
output_dirto a tracked path such assrc/generated/<feature>/. The generated.wadoand the<primary>.kiln.jsoncache file are both committed; PR review sees the source and a fresh checkout (or CI) hits the cache.build/kiln/itself stays gitignored — only the per-invocation cache file relocates withoutput_dir. CI useswado check(see "Thewado checkcommand") to enforce that the committed source still matches what the generator would produce now.
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:
from(the literal betweenuse ... fromandwith) is the primary schema.generator.inputs(optional) lists supplementary schemas. Each must be written as a./or../relative path and is resolved relative to the source file containing theuseclause — the same convention as a local module import; a bare path is rejected (reserved for a future manifest-root sigil). Transitive files beyond these — e.g. a.protothatimports another.proto— are picked up viahost.read-fileat generation time.generator.moduleis a module specifier string that follows the same rules as ause ... from "<source>"clause: a relative path beginning with./or../resolves against the file containing theuseclause; a[build-dependencies]specifier — an open coordinate<namespace>:<name>[@<version>]or alib:<nickname>indirection — resolves against[build-dependencies], dispatching on the matched entry's source (a path entry is compiled from source, a registry entry is pulled as a prebuilt component). Bare names are rejected, as bare[dependencies]keys are. A module path inside a multi-module package may be appended (for example"example:proto-codegen@1.2/generator"); the default is the package's root module.generator.version/generator.registry(optional) supply a registry generator's source inline, for a single-file script with nowado.toml. Whenmoduleis a[build-dependencies]specifier the manifest does not declare — the single-file case — they stand in for the[build-dependencies]entry:versionis the semver requirement andregistrytheoci://…URL (a direct URL, not a[registries]alias, since single-file mode has no[registries]; required when no[registries].defaultis in scope). A@versionpin inmoduleoverridesversion. This realizes design principle 3 — any generator configuration expressible inwado.tomlworks inline, so a single.wadofile can consume a published generator with no manifest. When the specifier is declared in[build-dependencies], the manifest entry is authoritative and an inlineversion/registryis an error (manifest and inline are mutually exclusive, as for single-file dependencies). A relative-pathmodulenever takes these fields.generator.output_dir(optional) redirects generated.wadofiles off the default location. Likefrom,inputs, and a relativemodule, it must be written as a./or../relative path and is resolved relative to the source file containing theuseclause; a bare path is rejected (reserved for a future manifest-root sigil). The default isbuild/kiln/<synthesized-id>, which (being synthesized rather than user-written) anchors at the build root: the manifest directory when awado.tomlis found, otherwise the entry file's directory, so single-file and packaged builds both centralize generated output under onebuild/kiln/. The compiler creates the directory if missing. Every file in the directory with a#![generated]attribute belongs to this invocation and may be overwritten or deleted by subsequent runs; files without that attribute are left alone and produce an error if they collide with a generator output path.- The
usestatement resolves against the invocation's entry module (see "Generator output layout" below). The imported symbols must bepubin that entry. The entry may re-export orusesupplementary generated modules; the consuming project does not need to know about those files. - LSP navigation:
- Go to definition on an imported symbol jumps to the generated
.wadothat defines it — ordinarily the entry module, but a symbol re-exported through the entry resolves to its true definition file as usual. - Go to schema on the
fromliteral jumps to the primary IDL; on an entry ininputsit jumps to that supplementary IDL.
- Go to definition on an imported symbol jumps to the generated
- The compiler synthesizes an internal
Invocation { module, from, inputs, output_dir, options }from each clause.module,from, andinputsare stored resolved relative to the declaring file (project-root-relative); the synthesized id used in the defaultoutput_diris thekiln-<16-hex>SHA-256 prefix of(module, from, inputs, options)over those resolved paths. Because the paths are resolved,decl_fileis intentionally not folded in: two files that import the same schema with the same generator and options share a single cache entry, while two files that each import their own same-named./grammar.g4from different directories resolve to distinct paths and so get distinct invocations. The unresolved literalfrom "<source>"string is retained separately as the loader-redirect key (the loader matches imports without resolving them).
De-duplication:
- Two
use ... withclauses with the same(module, from, inputs, options, output_dir)tuple — anywhere in the compilation unit — collapse into a single invocation, regardless of which file declared them. This lets a project split imports across multiple files —use { Lexer } from "./Rust.g4" with {...}anduse { Parser } from "./Rust.g4" with {...}— without running the generator twice. - If two clauses share a
frombut disagree on any ofmodule,inputs,options, oroutput_dir, that is a duplicate-generator error listing both declarations. The current compiler enforces single-configuration-per-fromacross the whole compilation unit (no per-file scoping); a future relaxation that would let two files have independent invocations for the samefromwould invalidate cache keys, so it is deferred. - A second
use { ... } from "./Rust.g4"(nowith) inside the same file as a clause that already carrieswithresolves through the existing invocation for thatfrom. A bareuseagainst the samefromin any other file is theCode::KilnMissingWitherror described above unless that file also declares a matchingwithclause.
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:
- Read
wado.tomland resolve[build-dependencies]into the build-dependency graph. Generator modules themselves are compiled lazily, so inlineuse ... withclauses may pull in additional modules not named in the manifest. - Parse every
.wadosource file in the project. Collect eachuse ... withclause as anInvocation { module, from, inputs, output_dir, options, decl_file }.useresolution is deferred to step 5. - 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-filetarget recorded on a previous run of A, lies inside B'soutput_dir. Topologically sort. Cycles are a compile error. - For each invocation in topological order:
a. Compute the cache key.
b. Read
<output_dir>/<primary>.kiln.jsonif it exists, where<primary>is the basename ofInvocation::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 aCode::KilnGeneratedModifiedwarning 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 awasm32-wasip3component if not already cached. This step reuseswado-compiler's ordinary codegen pipeline. d. Build aGeneratorRequest(primary + inputs + options) and callCompilerHost::run_generator(see "Host-delegated execution" for its contract). Eachread-filethe host relays back is appended to the invocation's dependency list for future cache-key computation. OnOk(response)continue with 4(e). OnErr(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 otherErr(...)the invocation fails with a hard compile error reported against its declaration. e. Persist eachoutput-fileto<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 aCode::KilnGeneratedRegeneratedwarning naming the file. Delete any existing#![generated]file inoutput_dirthat was not produced by this run. Write the new cache state to<output_dir>/<primary>.kiln.json. - Resolve every
usestatement. Ause { ... } from "<schema>" with { ... }binds to its invocation's entry module; bareuse { ... } from "<schema>"against a non-.wadoschema is theCode::KilnMissingWitherror unless another clause in the same file already registered an invocation for thatfrom. All otherusestatements 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:
- Authors never hand-spell the generated filename. The generator owns its own filename convention; rename it today and every consumer keeps compiling unchanged.
- The entry module file does not need to exist before the first compile. On a fresh checkout, the Kiln pipeline runs first (§"Build pipeline integration" steps 1–4), persists the file, and only then does name resolution see it.
go to definition/go to symbolin the LSP jumps to the schema position or to the generated file, not to a stale hand-written shim.
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:
wado_compiler::kiln::InvocationIndex— the lookup table. Entries are keyed by(decl_file, from_path_normalized)and point at the generated entry module's URI. Every entry is local to one declaring file: invocations are always declared inline, so the redirect only fires foruseclauses inside the same file as thewithclause. An unrelated file that happens tousethe same.g4path with nowithclause receives theCode::KilnMissingWitherror rather than being silently hijacked. The index is built once per compilation unit from the inline invocation set, after the pipeline has populated each invocation'soutput_dir. For consume-only mode (no pipeline run), the index is built from the recorded<primary>.kiln.jsonentry'soutputs[i].entry == truepath — that path lives on disk already or the cache check has failed.ModuleSource::Redirected { uri }— the compiler'sModuleSourcevariant that carries the redirect through the rest of the pipeline. Theuriis opaque to every phase after the loader: elaborator, analyzer, TIR / WIR, and monomorphizer all treat it as an identity token.
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:
wado testparses the file, collects the inlineuse ... withclause into anInvocationwhosedecl_fileispackage-gale/tests/driver_calculator_test.wado.- The Kiln pipeline compiles
src/generator.wadoto a component, runs it, and writes the generated module tobuild/kiln/<synthesized-id>/calculator.wado. - 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". - The loader's
resolve_import(andname::resolve_import_with_invocationson the analyze side) consults the index first. The lookup hits, so./grammars/calculator.g4resolves toModuleSource::Redirected { uri: "kiln:/…/calculator.wado" }. ModuleLoader::get_sourcestrips thekiln: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.- A second
use { ... } from "./grammars/calculator.g4"(nowith) inside the same file resolves to the sameRedirectedURI, so the module is loaded once and shares identity. The same bareusein any other file is rejected withCode::KilnMissingWith.
Failure modes and diagnostics
- The referenced schema file (
from) does not exist on disk. The generator'shost::read-filecall fails; the pipeline surfaces this as a compile error at the inlineuse ... with { ... }declaration site. - The generator runs but emits invalid Wado. The generated file is parsed by the ordinary frontend, so the user sees a normal parse / type error pointing at the on-disk file. Spans into the generated file carry the same
filefield the loader saw, so the LSP can open them. - The redirect fires but imported symbols are not
pubin the resolved entry module. This is a regularuseresolution error, not a Kiln-specific one. - Consume-only mode (no Kiln runtime available): the invocation index is built from the per-invocation
<primary>.kiln.json's last recordedoutputs[i].entry == truepath. If the file still exists with the recorded hash, compilation proceeds silently; otherwise aCode::KilnStaleCachewarning is emitted and the existing on-disk content is used as-is. See §"Transitional consume-only mode". - Two
use ... withclauses in the same file disagree on(module, inputs, options, output_dir)for the samefrom: duplicate-generator error listing both declarations. Two clauses in different files coexist as independent invocations, scoped to their respective files. - A bare
use { ... } from "./schema.<ext>"against a non-.wadosource in a file that has no matchingwithclause:Code::KilnMissingWith, pointing at the bareuseand suggesting thewith { generator: { module: ... } }form.
Generated file header
The compiler prefixes every generated file with a #![generated(...)] attribute:
#![generated(
by = "example:proto-codegen@1.2",
sources = ["./schemas/user.proto"],
)]
by— the generator package identity (namespace:name@version).sources— the list of IDL input paths that produced this file, relative to the project root. Every contributing input is listed on every output file the generator emits from those inputs.
#![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 resolved generator package identity (
namespace:name@version+ source-distribution content hash); - the content of
primaryand everyinputsentry; - every file the generator retrieved via
read-fileon the previous successful run; - the canonical serialization of
options; - the
kilnworld protocol version.
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:
outputs[].pathis project-root-relative (the same shapeoutput_diruses), so committed-source workflows record paths likesrc/generated/....outputs[].hashis recorded only in<primary>.kiln.json. The lockfile never carries it.<primary>.kiln.jsonis treated as a regenerable cache: deleting it (or losing the entirebuild/kiln/tree, or the entireoutput_dirfor committed-source) is not an error. The next compile rebuilds from scratch. Committed-source projects do commit the cache file because it lets a fresh checkout hit the cache; under the defaultoutput_dir, the file lives under gitignoredbuild/kiln/.
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:
- All non-codegen compiler phases (parse → analyze → annotate → build_tir → optimize) run normally. Type errors, missing imports, etc. surface exactly as they would under
wado compile. - For each Kiln invocation: load
<output_dir>/<path>from disk (if present), run the generator, compare bytes. A difference producesCode::KilnGeneratedStaleOnDisk, which--warnshows as a warning and the default mode promotes to an error. - Wasm emission is skipped, so
wado checkis meaningfully faster thanwado compile. - The
<primary>.kiln.jsoncache file is not consulted:wado checkanswers "would running the generator now produce what is committed?", which is independent of any cache state. The check therefore works identically on a fresh CI clone and on a developer machine.
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:
SourceMapregisters any file that comes in viaCompilerHost::load_source, including files read on behalf of a generator. Once a file has been read, it has a stableFileIdfor the rest of the compilation.- The generator-facing
source-spancarries(path, byte-start, byte-end). When the compiler materializes a diagnostic fromhost.emit-diagnostic, it looks up (or registers) theFileIdforpathand renders in its normal format. Ifpathwas 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:
- CBOR is binary — a generator decodes it without a text parse, and it represents
i64/u64/f64/ byte payloads without JSON's single numeric type and its precision ambiguity. - The blob remains the single source of truth shared by the cache key and the wire payload, so nothing downstream changes: the compiler-side encoder (
kiln::cache::encode_options_canonical) emits the bytes, they hash into the cache key, and they cross the wire verbatim. - The encoder only has to be deterministic and decodable by
core:cbor— it does not re-serialize on the generator side, so it need not matchcore:cbor's own canonical serializer byte-for-byte. It emits a definite-length map, field-name text keys sorted by encoded-byte order, shortest integer heads, 8-byte doubles (deserialize_f32narrows), and omitsOption::Nonefields.
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
generateargument.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:
Request<T>never crosses the WIT boundary — the wire type is the non-genericRawRequest, whoseoptionsis a CBORlist<u8>. The generic is pure Wado-side sugar: the adapter phase rewritesfn generate(req: Request<T>)(and the barefn generate(req: Request),T = NoOptions) into theRawRequest+bind_request::<T>shape, so authors get typed options with no boilerplate while the WIT contract stays monomorphic. Wado's default-type-parameter support makes the generic fully first-class rather than a two-form (Request<Options>vsRawRequest) compromise.- WIT emission must run this adapter too.
wado compile's section-embedding andwado witre-deriveSemanticsoff the codegen path viasemantics_for_world, which threads the target world so theRequest<T>rewrite is applied before analysis. Without it the WIT emitter sees the genericRequest<Options>— not representable in WIT — and silently drops the component-type section (issue #1478). The rewrittengenerate(req: raw-request)signature is fully representable. - The recursive
any-valuevariant the original design floated is likewise abandoned:wit-parserrejects recursive variants throughlist<...>, and CBOR already carries the same structural information. A future0.xbump could still add a richer typed path, but it is no longer on the roadmap as a JSON/CBOR replacement.
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>;
}
<options-record>is the generator'spub struct Optionslowered to a WIT record by the existing Wado→WIT mapping — enums to WITenums, nested structs to records,Option<T>tooption<T>, exact integer widths preserved. No CBOR, no JSON Schema, no fidelity loss. A generator with noOptionsomits theoptionsparameter entirely.- Consumer: at a
use … with { generator }site the compiler reads the resolved generator'sgeneratesignature — from source (path deps) or the component's WIT (registry deps, via Wasm CM Component Import) — and type-checks the user'soptions = { … }literal against the options-record type, lowering it through the canonical ABI like any typed component argument. The options type is discovered uniformly as a CM type; there is no bespoke descriptor, nodescribe-options, no schema encode/decode. - Author: unchanged surface —
pub struct Options { … }+fn generate(req: Request<Options>). The adapter assemblesRequest<Options>from the three typed arguments instead of decoding a CBOR blob; the guest receives typed options directly (nobind_request/core:cbor::from_bytes). - Host: because
generate's signature is per-generator, the host invokes it dynamically (wasmtime componentValcalls) rather than via a fixed static bindgen. The shared imports (kiln-host) and result shape (response/error) are identical across generators, so only the argument marshalling is generic. - Cache key: options no longer have a wire blob to double as the key. The invocation cache key uses a deterministic canonical encoding of the validated options value, retained solely as a key function — not a wire format.
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.
- Source / path generator (
GeneratorModule::LocalPath): the descriptor is extracted from the generator's Wadopub struct Options(kiln::options). It carries per-field defaults, source spans, and exact integer widths. - Registry generator (
GeneratorModule::Spec, a prebuilt component): the descriptor is recovered from the component WIT (wado-cli::kiln_wit). A WITrecordhas no notion of a field default, so every field is required at the consumingusesite unless its type isoption<T>(whose absence resolves toNone).
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:kiln → core: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.
- Stream 1: lower-side CM emission (
emit_kiln_world_types, asynctask-returnrouting viastore.run_concurrent). - Stream 2: canonical-JSON cache key (
v2MAGIC),Request<T>adapter rewrite, Gale v2 migration. - Stream 3:
OptionsDescriptorserialization + cache;CompileResult.kiln_options_descriptor. - Stream 4: Gale kiln-consumer test harness;
kiln_driver::loweraccepts inlinemodule: "<specifier>"(relative path or<ns>:<name>@<ver>). - CM name-set wrap-up:
core:kiln/types@0.1.0becomes an importedInstanceTypeso wasm-tools accepts the component (skip_validation: false). List<T>lower: realrealloc + IndexValue + synthesize_lower_wasi_type_to_memoryloop inlower_to_flat_inner;LiftContextthreaded through every CM lower path that touches it.- Generator determinism:
wir_build/calls.rsemitsUnreachablefor stderr/stdout write builtins under the kiln-generator world;optimize/dce.rsskips the matching WASI import. Compiled generators import onlycore:kiln/kiln-host+core:kiln/types. - Driver-test rewrite +
O2for generator compile: 19 ofpackage-gale/tests/driver_*_test.wadoswitch from importingtests/golden/<name>.wadoto importing the matching.g4with an inlinewith { generator: { ... } }clause;CliGeneratorProvider::compile_localrises fromO0toO2so Kiln-driven generator compiles match theupdate-gale-goldenflow. - Refactor stream — replace ad-hoc workarounds with first-class designs (no behavior change for the happy path,
wado test package-gale/tests310/310 +cargo test -p wado-compiler --test e2e5455/5455 stay green):ModuleSource::Redirected { uri }variant carries akiln:/abs/pathURI through loader → elaborator → analyze → WIR.kiln:(single colon) avoids the//collision with{module_source}//{name}qualified-name keys thatfile://would introduce. The compiler stayswasm32-unknown-unknown-friendly viafluent_uri::UriRef.Invocation::raw_options: Option<AttrValue>stashes options at lowering time;typed_encode_optionsreads it directly instead of re-parsing the source file (reparse_inline_optionsdeleted).DeclScope::{Any, LocalTo(String)}enum replaces thedecl_file = ""sentinel for "match any importer".type_id_to_ast_typepopulatessource_interfaceonly when the TIRmodule_sourceproves the type is from a CM namespace (Wasi { .. }orCore { name: starts_with("kiln") }), so user-local structs that share names with WASI / kiln records keepsource_interface = None.emit_kiln_world_typesdrops the mirrorlocal_idxcounter +#[allow(unused_assignments)]shims in favor of smallemit_record / emit_variant / emit_list / emit_exporthelpers.
M6 is complete. Remaining follow-ups (independently reviewable; each keeps cargo check -p wado-compiler --target wasm32-unknown-unknown green):
- [x] M7 LSP integration — see §"M7 — Consume-only mode".
- [x] End-to-end Kiln-driven Gale invocation — covered by
package-gale/tests/driver_*_test.wado(calculator + SQLite + the 17 other grammars), all of which run the generator live viawado test.
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 parse → load → semantics_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.
- [x] Remove the
[build.generators.<name>]section fromwado-manifest(BuildSection,GeneratorInvocation,GeneratorModuleRef,validate_build, related error variants and tests). - [x] Drop
DeclScope::Anyfromwado-compiler::kiln::inline; the redirect index is keyed solely by(decl_file, from), and a bareuseagainst a non-.wadoschema fails withCode::KilnMissingWithunless another clause in the same file already registered an invocation. - [x] Drop
DeclSite::Manifestand themerge_manifest_and_inlinepath;Invocationis constructed only from inline clauses. - [x] Replace
wado-cli::kiln_driver::run_pipelinewith the inline-only entrypoint (formerlyrun_pipeline_with_inline), dropplan()/lower(), and synthesize lockfile invocation ids from(decl_file, from). - [x] Migrate the four in-tree consumers off
[build.generators.parser]to inlinewithclauses:benchmark/sqlite_parse/,benchmark/syntax_highlight/,wasm-size/sqlite_highlight/,package-gale/tests/kiln-consumer/. - [x] Rewrite
wado-cli/tests/kiln_pipeline.rsagainst synthesized inline invocations; removewado-cli/tests/kiln_gale_path.rs(subsumed by the existing inline driver tests).
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.
- [ ] Drop
GeneratorCacheEntry,OutputHash, the[[generator-cache]]schema and serializer inwado-manifest/src/lockfile.rs. Old lockfiles with the section parse as before (unknown fields are tolerated by serde) but new writes never emit it. - [ ] Introduce
wado-cli/src/kiln_metadata.rswith the JSON serde types andload(invocation_dir) -> Option<Metadata>/save(invocation_dir, metadata) -> io::Result<()>. Path:<manifest_root>/build/kiln/<synthesized-id>/metadata.json. - [ ] Replace the lockfile-based cache check in
wado-cli/src/kiln_driver.rs(run_pipeline,cache_matches,build_cache_entry) with a metadata.json-based check. AddCode::KilnGeneratedModified(hand-edit detected on cache hit) andCode::KilnGeneratedRegenerated(overwriting an existing-but-different file on cache miss). - [ ] Add
Cmd::Checktowado-cli/src/main.rsandwado-cli/src/check.rs. Force-regenerates every invocation, byte-compares against on-disk, surfacesCode::KilnGeneratedStaleOnDisk(default error,--warnkeeps as warning), skips Wasm codegen. - [ ] Migrate the four committed
wado.lockfiles (package-gale/,benchmark/sqlite_parse/,benchmark/syntax_highlight/,wasm-size/sqlite_highlight/) to remove their[[generator-cache]]sections. - [ ] Update CI to run
wado check(or piggy-back on the existingmise run benchmark-all/report-wasm-sizeflow) so committed-source freshness is enforced.
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.
Link wasmtime into wado-compiler
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:
wado-compilermust build forwasm32-unknown-unknown— enforced by CI (.github/workflows/ci.yml: check-wasm32) and required becausewado-lspbundles the compiler as awasm32-wasip2component for VS Code Wasm and the browser playground (wep-2026-04-18-lsp-architecture.md). wasmtime does not build forwasm32-unknown-unknown.- 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:
- The reader of a
.wadofile could not tell thatuse sqlite from "./SQLite.g4"was being processed by Gale without consulting the siblingwado.toml. Generator identity, options, and even the existence of generation were invisible at the call site. - A manifest entry was a project-wide rewrite hook: any file that happened to mention the same
frompath was silently routed through the generator. Inline clauses in different files could fight over the samefrom.
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.
- [ ] Watch mode (
wado compile --watch). The caching design already supports it; v1 leaves the command surface unspecified. - [ ] Parallel execution of independent invocations — an implementation choice.
- [ ] Richer shape for
response.error. The currentinvalid-schema | unsupported | othervariant is extendable in a0.xbump. - [ ]
host.read-fileaccess scope. V1 serves any pathCompilerHostaccepts; a stricter per-invocation allow-list may be revisited if needed. - [ ]
root: "workspace"escape hatch for project-root-relative paths atusesites. Not needed in v1. - [ ] Web-host routing for
run_generator. When VS Code Wasm or the browser playground gains CM + WASI p3, the host-hosts-host arrangement (wado-lsp-in-Wasm asks its embedder to run the generator-in-Wasm) needs a concrete transport. Consume-only mode is the v1 stopgap. - [ ] Streaming / progress reporting through
CompilerHost::run_generator. V1 is blocking; long-running generators block the compile. - [ ] Fuel or timeout budget for
run_generator. The CLI host currently runs with no ceiling (KilnRunPolicy::default().fuel == 0, mapped tou64::MAX). An early 1 GiB fuel default tripped on the SQLite-sized Gale grammar even though the same generator runs unbounded underwado run, so picking a one-size value was deferred. Follow-up: expose the ceiling as an inline-withknob (likelywith { ..., limits: { fuel, timeout_ms } }) and pair it with an epoch-interrupt wall-clock deadline so trusted local builds and untrusted CI jobs can pick different policies.KilnRunPolicyis the in-tree extension point.
Consequences
- Users gain a first-class way to consume any schema language that has (or can acquire) a Wado generator package. Operationally close to Swift macros: sandboxed, deterministic, cacheable, automatic.
wado.tomlgrows a[build-dependencies]section and a new[package].generatorwell-known-world field (joiningcommand,service,lib);wado.lockgrows[[build-dependency]]only. Per-invocation cache state lives next to the outputs as<output_dir>/<primary>.kiln.json— gitignored under the defaultoutput_dir, committed alongside generated.wadowhenoutput_diris tracked. Allwado.toml/wado.lockchanges land inwado-manifest, which is alreadywasm32-unknown-unknown-ready; the cache-file I/O lives inwado-clibecause reading and writing the file is host-side work.wado-compilergains a phase between parsing and name resolution that plans generator invocations, compiles generator modules to components, hands them toCompilerHost::run_generator, and materializes the returned output. The core compiler remains I/O-free and runtime-free; wasmtime is never linked into it, andcargo check -p wado-compiler --target wasm32-unknown-unknowncontinues to pass.CompilerHostgrows one method (run_generator).CliCompilerHost(inwado-cli) implements it via wasmtime; hosts that cannot run generators today returnUnsupportedand the compiler degrades to consume-only mode.CompilerHostserves generator-relayedread-filecalls on top of user-source reads.SourceMapand the diagnostic renderer extend to spans in generator-read files; existing user diagnostics are unaffected.wado-lsp(wasm32 build) and the browser playground initially run in consume-only mode. Projects that want a full LSP experience in web editors should commit the generated source by settingoutput_dirto a tracked directory (e.g.src/generated/); native-only projects can leaveoutput_dirat its default and keep all ofbuild/kiln/gitignored.- Gale migrates from
mise run update-gale-goldento inlineuse ... with { generator: { ... } }invocations; the update task is retired.wado-from-idlis unchanged. - Adding a new schema language becomes a matter of publishing a package that exports
core:kiln/generator; no compiler changes are required.
References
wep-2026-01-16-source-provider-abstraction.md—CompilerHost, the I/O boundary this WEP builds on.wep-2026-02-14-package-manifest.md—wado.tomlandwado.lock, to which this WEP adds new sections.wep-2026-01-29-wit-wado-mapping.md— the Wado→WIT mapping used to type the generator'sOptions.wep-2026-03-02-include-str.md— compile-time file inclusion, complementary to this WEP.wep-2026-04-01-tide.md— the WebIDL/WIT binding generator, which remains Tier 0.wep-2026-03-02-gale.md— the ANTLR grammar generator, to be migrated to this framework.wep-2026-01-18-json-module-import.md— superseded: JSON import becomes a Kiln generator.wep-2026-01-14-compiler-pipeline-refactoring.md— pipeline phases, into which Kiln is inserted.research-code-generation.md— orthogonal research on generator-authoring ergonomics (reducingout.push_str()noise).
