WEP: Jade — JSON Schema for Wado
Context
Wado programs increasingly sit at the boundary between Wado's static type
system and the dynamically-typed JSON world: a wasi:http/service handler
must document and validate the request bodies it accepts, a client must check
a third-party response before trusting it, and a project that already owns a
hand-written JSON Schema wants idiomatic Wado types generated from it. Today
none of this is served: core:json round-trips bytes through Serialize /
Deserialize, and core:value::Value models an arbitrary document, but there
is no way to describe the permitted shape of a document — let alone derive
that description from a Wado type, check a value against it, or turn it into
Wado source.
"JSON Schema" is a single phrase that, across the Go and Rust ecosystems, actually names four independent capabilities. Conflating them produces a muddled API; separating them is the first design act.
| Capability | Input → output | Rust prior art | Go prior art | |
|---|---|---|---|---|
| A | Schema document model + I/O | JSON Schema document ⇄ typed tree | schemars::schema |
invopop/jsonschema Schema |
| B | Type → schema derivation | Wado type → schema document | schemars (#[derive(JsonSchema)]) |
invopop/jsonschema (struct-tag reflection) |
| C | Schema → value validation | (schema, JSON value) → result | jsonschema crate, valico |
santhosh-tekuri/jsonschema, xeipuuv/gojsonschema |
| D | Schema → Wado type codegen | .json schema → generated .wado |
typify, quicktype |
go-jsonschema, quicktype |
The two ecosystems weight these differently, and the contrast is instructive:
- Rust keeps B (schemars) and C (the
jsonschemacrate) in separate crates. schemars is tightly coupled to serde —#[serde(rename_all, default, skip)]flows straight into the emitted schema, so the wire shape and its description never drift. - Go is a struct-tag-reflection culture, so B (
invopop/jsonschema, readingjson:"…"+jsonschema:"…"tags) is the center of gravity. Validation compliance peaks insanthosh-tekuri/jsonschema: full Draft 2020-12,$ref/$dynamicRef, format assertions, and pluggable remote-ref loaders, returning errors keyed by JSON Pointer.
Wado is positioned to do better than either, because the pieces these ecosystems bolt on after the fact already exist as first-class infrastructure:
- serde derive + attributes (
#[serde(rename_all)], field defaultsf: T = expr) — capability B reuses them exactly as schemars reuses serde, so the derived schema and theSerializewire form are guaranteed consistent. Reflect— compile-time structural introspection (wep-2026-03-14-variadic-type-parameters.md, §10) — a compiler-synthesized trait exposing a struct's fields as a typed tuple (type Fields = [..F],field_names()). This is the static, monomorphized, no-dynamic-reflection mechanism — the same lineage as tuplefor-ofand[..T::default()]— that lets capability B be written as ordinary library code rather than compiler magic. It is the linchpin (see "Language dependency").core:value::Value— the validation instance model for capability C; the roleserde_json::Valueplays in Rust andinterface{}in Go.core:json(from_bytes/to_bytes) — capability A's schema documents are themselvesSerialize + Deserialize, so I/O is free.- Kiln (
wep-2026-04-12-kiln.md) — capability D is exactly a Kiln generator (use { User } from "./user.schema.json" with { generator: … }), the same mechanism as Gale (.g4) and Tide (WebIDL). - Format-bearing stdlib —
core:temporal(date-time),core:url(uri),core:uuid(uuid) supply theformatassertions other ecosystems reimplement.
Scope
In scope (the eventual whole): all four capabilities A–D, targeting JSON
Schema Draft 2020-12, delivered as an independent package wado:jade
(directory package-jade/), not bundled into core:.
Out of scope:
- Drafts other than 2020-12 at first. Draft 7 / OpenAPI 3.0 compatibility
(notably
nullable,definitions, no$defs) is a later additive reader, not a parallel validator. - Hyper-Schema (
links,base), and the full output-format vocabulary (unevaluatedPropertiesinteractions are in scope; the standardized verbose/hierarchical output units are a later refinement). - Fetching remote
$reftargets by default. Remote resolution is exposed only through an explicit effect-carrying loader (see "Validation").
Why a package, not core:
Jade is heavy (a validator, a derive surface, and a Kiln generator), evolves on
the JSON Schema spec's cadence rather than the compiler's, and mirrors
package-gale precisely: a Wado package that is also a Kiln generator. Same
rationale Kiln used to keep Gale out of the compiler binary. core:json /
core:value / core:serde stay the minimal, always-present substrate; Jade is
opt-in.
Decision
Name
Jade — JSON Assertion & Definition Engine.
- JSON — the format it serves; the leading
jmakes that legible at a glance. - Assertion — capability C, validating a value against a schema.
- Definition — capabilities A and B (authoring / deriving a schema document) and D (defining Wado types from one).
- Engine — the unifying runtime + generator.
Jade is a gemstone that is carved to shape — the act a schema performs on
data. It echoes the gem/craft register of the ecosystem's tool names (the
"facet" of a cut stone is XSD's own word for a schema constraint) and sits at
four letters alongside Gale, Kiln, and Tide. Imported as wado:jade@0.1.
Layering
Jade is four layers, each usable on its own and stacked so that the layer below never depends on the layer above.
D codegen .json schema → .wado types (Kiln generator)
C validation (Schema, Value) → ValidationResult
B derivation JsonSchema: T → Schema (library code over `Reflect`)
A model Schema (Serialize + Deserialize via core:json)
Layer A — schema document model
A single Schema type that round-trips through core:json with no per-type
work, modeling Draft 2020-12. The open question (see "Consequences") is
typed tree vs. Value-backed; the decision is a hybrid: a typed
struct Schema for the known keywords plus a TreeMap<String, Value> escape
hatch (extra) so unknown / vocabulary-specific keywords survive a
round-trip — the same pattern core:value::Value::Unknown uses for opaque
bytes.
// package-jade/src/model.wado (sketch)
pub struct Schema {
// A boolean schema (`true` / `false`) is the degenerate case.
bool_schema: Option<bool>,
// Core
id: Option<String>, // $id
schema: Option<String>, // $schema
ref_: Option<String>, // $ref
defs: TreeMap<String, Schema>, // $defs
// Applicators
type_: Option<TypeSet>, // "string" | ["string","null"]
properties: TreeMap<String, Schema>,
required: List<String>,
items: Option<Schema>,
prefix_items: List<Schema>,
all_of: List<Schema>, any_of: List<Schema>, one_of: List<Schema>,
not_: Option<Schema>,
// Assertions (validation vocabulary)
enum_: List<Value>, const_: Option<Value>,
minimum: Option<f64>, maximum: Option<f64>,
min_length: Option<i64>, max_length: Option<i64>,
pattern: Option<String>, format: Option<String>,
// … (full keyword set elided)
// Annotations
title: Option<String>, description: Option<String>,
default_: Option<Value>, examples: List<Value>,
// Anything Jade does not model, preserved verbatim.
extra: TreeMap<String, Value>,
}
The _-suffixed fields (type_, ref_, …) carry #[serde(rename = "type")]
etc., reusing the serde rename machinery so the wire form is exact JSON Schema.
Layer B — JsonSchema derivation (schemars-equivalent)
Wado has no dynamic reflection and no macros, so "given a type, produce its
schema" is impossible as plain library code unless the compiler exposes a
type's structure at compile time. It does — or will: Reflect
(wep-2026-03-14-variadic-type-parameters.md,
§10). JsonSchema is therefore an ordinary generic library trait that Jade
writes on top of Reflect, not a compiler-synthesized one. The compiler
knows nothing about Jade.
pub trait JsonSchema {
// Stable name used as the $defs key and $ref target.
fn schema_name() -> String;
// Whether to inline or emit a $ref into $defs (true for named composites).
fn is_referenceable() -> bool { return true; }
// Build the schema, registering dependencies into the generator.
fn json_schema(gen: &mut SchemaGenerator) -> Schema;
}
// Driver: a single type → a self-contained root schema with $defs.
pub fn schema_for<T: JsonSchema>() -> Schema;
The structural impl JsonSchema for any struct is written once, generically,
by walking Reflect's field pack — the same compile-time [..F::method()]
expansion [..T::default()] already uses, with no runtime cost and no
per-struct boilerplate. The where-clause pack binding is the form established
by WEP 2026-03-14 §11:
// Jade library code (illustrative): structure from Reflect, value-free.
impl<T, ..F: JsonSchema> JsonSchema for T
where T: Reflect<Fields = [..F]>
{
fn schema_name() -> String { return T::type_name(); }
fn json_schema(gen: &mut SchemaGenerator) -> Schema {
let meta = T::field_meta(); // wire_name, has_default, doc, validate
let field_schemas = [..F::json_schema(gen)]; // recurse per field type, no instance
return object_schema(meta, field_schemas); // required = fields without a default
}
}
field_meta() is the per-field metadata Reflect exposes (see "Language
dependency"): the wire name, whether the field has a default, its doc string,
and its #[validate] entries. A variant / enum / flags type derives
through the analogous ReflectVariant / ReflectEnum / ReflectFlags blanket
impls — a variant to oneOf, an enum to a string enum.
It shares the serde metadata: #[serde(rename_all = "camelCase")] renames
properties keys, a field default f: T = expr drops a field from
required, and a variant lowers to oneOf with the same tag representation
core:json emits. SchemaGenerator carries settings (target draft,
inline-vs-$ref policy) and accumulates $defs, exactly as schemars'
SchemaGenerator does — this is how recursive types
(struct Node { next: Option<Node> }) terminate via a $ref.
Constraints and annotations come from two non-#[jade] sources:
description/titlefrom///doc comments — the idiomatic source (schemars does the same); no attribute needed.- Validation keywords (
min_length,minimum,pattern,format, …) from the generic, built-in#[validate(…)]attribute — not a Jade-owned namespace. Jade reads its entries viaReflect; the same attribute is enforced at theDeserializeboundary, so it is never inert. The attribute is specified in the companion WEP (see "Language dependency").
#[serde(rename_all = "camelCase")]
struct CreateUser {
/// The user's login name.
#[validate(min_length = 1, max_length = 64)]
user_name: String, // → {"type":"string","minLength":1,"maxLength":64,
// "description":"The user's login name."}
#[validate(format = "email")]
email: String,
#[validate(minimum = 0, maximum = 150)]
age: i32 = 0, // default ⇒ not required
/// ISO-4217 code.
currency: String,
}
Layer C — validation (santhosh-tekuri-equivalent)
Validate a Value (or raw bytes, parsed via core:json into a Value)
against a compiled Schema, returning every failure keyed by JSON Pointer,
mirroring the offset-carrying discipline of DeserializeError.
pub struct Validator { /* compiled schema + $ref resolution table */ }
pub fn compile(schema: &Schema) -> Result<Validator, SchemaError>;
impl Validator {
pub fn validate(&self, instance: &Value) -> ValidationResult;
}
pub struct ValidationError {
instance_path: String, // JSON Pointer into the instance: "/items/2/age"
schema_path: String, // JSON Pointer into the schema: "/properties/age/minimum"
keyword: String, // "minimum"
message: String,
}
pub variant ValidationResult { Valid, Invalid(List<ValidationError>) }
Design commitments:
- Compile once, validate many.
compileresolves all local$ref/$defs/ anchors into a flat table up front;validateis allocation-light. formatis asserting and pluggable. Built-in assertions delegate:date-time→core:temporal,uri→core:url,uuid→core:uuid,regex→ the pattern engine below. Per the spec,formatis annotation by default; Jade exposes a flag to make it asserting.- Remote
$refis an effect.compileresolves only local references. Schemas that reference external documents take an explicit loader:compile_with(schema, loader)whereloader: fn(uri) -> Result<Schema, …> with E. The default is a registry of pre-supplied documents (no I/O); a user wireswith Http(or filesystem) themselves. This keeps the validator pure and Wasm-sandbox-friendly by construction. - Regex.
pattern/format: regexneed an engine. Jade ships a small ECMA-262-subset matcher rather than taking a heavy dependency; the subset is documented and unsupported constructs error atcompile, not silently pass.
Layer D — codegen (typify-equivalent, via Kiln)
A Kiln generator (package-jade/src/generator.wado, declared generator =
in wado.toml) that lowers a .json schema into Wado type declarations,
identical in shape to how package-gale lowers .g4:
use { CreateUser } from "./create-user.schema.json" with {
generator: { module: "wado:jade@0.1" },
};
object → struct (with #[validate] / #[serde] attributes reconstructed
from the keywords), oneOf of tagged objects → variant, enum of strings →
enum, $ref → a named type reference, $defs → sibling declarations. This
closes the loop: Layer B turns Wado types into schemas, Layer D turns schemas
back into Wado types, and the two are designed to round-trip.
Language dependency — Reflect and #[validate]
Jade itself ships zero compiler changes; everything in Layers A, C, D is plain
library code. But Layer B (and, longer-term, moving serde derivation out of
synthesis/serde_synth.rs into library code) needs the compiler to expose type
structure. That work is not Jade's to define inline — it is general language
infrastructure, specified in the companion WEP
wep-2026-06-13-reflect-derivation.md
(which evolves
wep-2026-03-14-variadic-type-parameters.md
§10). Jade depends on it; this section only records what Layer B requires of
it, so the two designs compose.
What Layer B consumes from the companion WEP:
Reflectsynthesis +where-clause pack binding — powers the value-freeimpl<T, ..F> … where T: Reflect<Fields = [..F]>above.- Per-field
Reflectmetadata — wire name (serde rename), has-default (→required), doc string (→description). - Variant / enum / flags reflection — for
oneOfand stringenum. - The generic, built-in
#[validate(…)]attribute — Jade reads its entries viaReflectfor schema keywords; it is also enforced at theDeserializeboundary, so it is never inert.
The companion WEP owns the full design and the future directions it implies —
refinement foo: T where … predicates (in-memory invariants, distinct from
#[validate]'s wire-boundary guard) and the user-defined @[foo(…)] attribute
syntax.
Phasing
The layers differ in what they depend on, and the phasing follows that, not a fixed numeric order:
- A — schema model first. Pure library over
core:serde/core:json/core:value; shippable immediately and the foundation every other layer builds on. - C — validation and D — codegen are also pure library (on top of A and Kiln respectively) and need no compiler change, so they can proceed in parallel once A exists.
- B — derivation is the only layer gated on the companion language WEP
(
Reflectsynthesis, per-field metadata,#[validate]). It lands as those pieces land; until then a schema is hand-authored on the Layer A model. B is the headline feature — API-doc / OpenAPI emission for awasi:http/service— so it is what drives the companion WEP's priority.
Each phase is its own follow-up WEP/PR; this document fixes the end state so the phases compose rather than collide.
Consequences
Benefits
- One coherent package spanning describe / derive / validate / generate, each layer independently useful, no layer depending upward.
- Derived schemas cannot drift from the
Serializewire form, because both are generated from the same serde metadata — a guarantee neither schemars (manual re-annotation possible) nor Go reflection fully provides. - Validation and codegen reuse
core:value/core:json/ Kiln rather than reinventing a parser, a document model, or a build pipeline. formatassertions lean oncore:temporal/core:url/core:uuid, turning a notorious interoperability weak point into a strength.
Costs and trade-offs
- A conformant Draft 2020-12 validator is a large surface (
$dynamicRef,unevaluatedProperties, annotation collection). Phasing the validator (C) after the model (A) contains the risk but does not remove it. - Shipping a regex engine — even an ECMA-262 subset — is real work and a
permanent maintenance item. The alternative (no
patternsupport) is not credible for a JSON Schema tool. - The hybrid
Schemamodel (typed fields +extraescape hatch) trades a little type-safety for round-trip fidelity of unknown keywords. The fully-typed alternative loses vocabulary extensions; theValue-only alternative loses ergonomics for the 95% common case. The hybrid matches the precedent already set bycore:value::Value::Unknown.
Open questions
- Draft 7 / OpenAPI reader. Additive (a normaliser into the 2020-12 model) or a separate parse path? Deferred until A+B land.
#[validate]keyword surface. Which JSON Schema assertions are worth a first-classkey = value(min_length,maximum,pattern,format,multiple_of, …) versus left to hand-authoring on theSchemavalue. The attribute mechanism is settled (a single generic built-in, enforced at theDeserializeboundary and read viaReflect); only the recognized key set is open.- Output format units. Whether to emit the spec's standardized
verbose/hierarchical validation output (for tooling interop) or keep Jade's
own flat
ValidationErrorlist. Tentatively: flat list first, standardized output later if a consumer needs it.
Resolved during design
- No
#[jade(…)]attribute. A package that merely lives in this repo is an ordinary library; it cannot own a compiler-recognized attribute, and Wado has no reflection or macros for it to read one itself. Constraints therefore come from a generic built-in#[validate(…)]— enforced at theDeserializeboundary so it is never silently inert, and read viaReflectfor schema emission — anddescriptionfrom///doc comments. See "Language dependency". - Capability B is library code, not compiler magic. It is built on the
compile-time
Reflectintrospection, in the same no-dynamic-reflection, monomorphized lineage as tuplefor-of. The compiler gains general type introspection; it never learns about Jade.
Implementation status
- Capability A (Schema document model) — minimal, in
package-jade. ASchemastruct for the Draft 2020-12 subset needed to describe Kiln generator options (objects, primitives, stringenum,required,default,additionalProperties, type unions withnull) plus a hand-writtenSerializethat omits absent fields, so the emitted schema stays idiomatic. Serves both JSON (core:json) and CBOR (core:cbor). Deserialize and the full keyword surface are follow-ups. - Capability B (type → schema) is blocked on
Reflect(unimplemented). The first consumer, Kiln'sdescribe-options(see the Kiln WEP), sidesteps it: the compiler derives the schema from a generator's source-extractedOptionsDescriptorin Rust and emits apackage-jadeSchema. Library-level#[derive(Jade)]lands withReflect. - Capability C (validation) and D (codegen) are future work.
