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 —#[wire(name_policy, 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 (
#[wire(name_policy)], field defaultsf: T = expr) — capability B reuses them exactly as schemars reuses serde, so the derived schema and theSerializewire form are guaranteed consistent. ReflectStruct— 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 FieldTypes = [..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). - Format-bearing stdlib —
core:temporal(date-time,date,time,duration),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 and Kiln. 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 `ReflectStruct`)
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>,
}
A struct-field position accepts any keyword, so every key can be spelled after
its JSON Schema name: type (a contextual keyword) and the plain identifiers
ref / not / default need no adornment. enum_ / const_ keep a _ suffix
only by convention — the hard keywords enum / const are fine as field names
but not as plain bindings — and still serialize under the exact JSON Schema key.
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: ReflectStruct
(wep-2026-03-14-variadic-type-parameters.md,
§10). JsonSchema is therefore an ordinary generic library trait that Jade
writes on top of ReflectStruct, 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 ReflectStruct's field pack — the same compile-time [..F::method()]
expansion [..T::default()] already uses, with no runtime cost and no
per-struct boilerplate. The pack binding is the inline-bound form core:serde's
own blankets use (WEP 2026-03-14 §11):
// Jade library code (illustrative): structure from Reflect, value-free.
impl<T: ReflectStruct<FieldTypes = [..F]>, ..F: JsonSchema> JsonSchema for T {
// `type_name()` names the declaration; a `$defs` key is per instantiation.
fn schema_name() -> String { return key_of(Reflect::<T>::type_info()); }
fn json_schema(gen: &mut SchemaGenerator) -> Schema {
let meta = T::field_meta(); // wire_name, has_default, doc
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 ReflectStruct exposes (see "Language
dependency"): the wire name, whether the field has a default, and its doc
string. 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: #[wire(name_policy = "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 sources:
description/titlefrom///doc comments — the idiomatic source (schemars does the same); no attribute needed.- Assertion keywords (
minLength,minimum,pattern,format, …) from the field's type, which declares its own permitted range throughConstrained. Jade'simpl<T: Constrained + Reflect> JsonSchema for TtakesT::Base's schema for the shape and adds theConstraint's keywords —{"type":"string"}from the base,minLengthfrom the constraint — while the$defskey comes from the identity root, which a constrained type satisfies whether it is a newtype or a struct wrapper (a kind bound would admit only one). A check at a boundary reads the sameConstraint, so it and the schema cannot disagree, and the rule is stated once however many structs carry the type.
type UserName = String; // impl Constrained: min_length 1, max_length 64
type Email = String; // impl Constrained: format "email"
type Age = i32; // impl Constrained: minimum 0, maximum 150
#[wire(name_policy = "camelCase")]
struct CreateUser {
/// The user's login name.
user_name: UserName, // → {"$ref": "#/$defs/UserName"}, and in $defs:
// {"type":"string","minLength":1,"maxLength":64,
// "description":"The user's login name."}
email: Email,
age: Age = 0, // default ⇒ not required
/// ISO-4217 code.
currency: String, // unconstrained: just {"type":"string"}
}
A named constrained type also earns a $defs entry and a $ref per use, where
a per-field attribute could only inline the same keywords at every site.
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/date/time/duration→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 #[wire] attributes reconstructed from the naming),
a schema carrying assertion keywords → a newtype plus its Constrained impl,
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.
Constrained
A boundary checks a value's permitted range — a name at most 64 bytes, an age
in 0…150, a currency code matching ^[A-Z]{3}$. The check belongs to the
type, not to the field holding it: declared once, it guards every boundary
that admits the type. A per-field attribute would restate the rule at each
field and leave the type itself unconstrained. core:serde checks shape only
and leaves range to Jade, which owns the regex engine and the format
vocabulary a full check needs.
pub struct Constraint {
pub min_length: Option<i64> = null, pub max_length: Option<i64> = null,
pub minimum: Option<f64> = null, pub maximum: Option<f64> = null,
pub exclusive_minimum: Option<f64> = null, pub exclusive_maximum: Option<f64> = null,
pub multiple_of: Option<f64> = null,
pub min_items: Option<i64> = null, pub max_items: Option<i64> = null,
pub unique_items: bool = false,
pub pattern: Option<String> = null, pub format: Option<String> = null,
}
pub trait Constrained {
type Base: Deserialize; // `type UserName = String` → String
fn constraint() -> Constraint;
}
Base is what the check reads: the underlying value is decoded, checked, and
converted. It is also what Layer B delegates to for the shape the keywords
qualify: {"type":"string"} comes from Base, minLength from the
Constraint. A newtype is the natural vehicle, since it inherits its base's
behaviour and declaring the constraint is the whole definition. The compiler
rejects this impl on a newtype for now (Open questions):
type UserName = String;
impl Constrained for UserName {
type Base = String;
fn constraint() -> Constraint {
return Constraint { min_length: Option::Some(1), max_length: Option::Some(64) };
}
}
The constraint is a value, so the schema Layer B emits and the check a
boundary runs read the same constraint() and cannot drift. Neither schemars
(its #[validate] equivalent lives in a separate crate that serde never
consults) nor Go's tags close that gap.
A newtype's own value is not guarded: "" as UserName and a literal coercion
both bypass the check, because a newtype's From is bidirectional and free
(Conversion Traits §7). A type that
must hold its invariant against in-process construction needs a struct wrapper
with a private field and a TryFrom constructor instead.
Language dependency — ReflectStruct
Jade itself ships zero compiler changes; everything in Layers A, C, D is plain
library code. But Layer B needs the compiler to expose type structure — the same
dependency that moved serde derivation out of synthesis/serde_synth.rs. 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:
ReflectStructsynthesis + pack binding — powers the value-freeimpl<T: ReflectStruct<FieldTypes = [..F]>, ..F> …above.- Per-field
ReflectStructmetadata — wire name (serde rename), has-default (→required), doc string (→description). - Variant / enum / flags reflection — for
oneOfand stringenum. Reflect/TypeInfo— the identity root every nameable type carries, and a$defskey that separatesPair<String>fromPair<i32>.
The companion WEP owns the full design.
Assertion keywords are not on that list: they come from
Constrained, which is Jade's own library code rather than a
language feature it waits for.
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 —
now on its remaining two pieces (
Member::doc()andTypeInfo), the reflection layer itself having landed. 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.
Constraintkey surface. Which JSON Schema assertionsConstraintcarries versus what is left to hand-authoring on theSchemavalue. The JSON Schema assertion vocabulary bounds it.- How a
Deserializeboundary reachesConstrained. The orphan rule forbids Jade'simpl<T: Constrained> Deserialize for T: both the trait and the self type are foreign to Jade (Traits). Nothing yet says what runs the check when a constrained type is decoded. - What builds a constrained newtype from its checked
Base. A struct wrapper works:Constrained: From<Self::Base>reaches theimpl From<String>it writes. A newtype'sFromis auto-generated (Conversion Traits §7), and the supertrait check does not see it, so everyConstrainedimpl on a newtype is rejected as not implementingFrom. - Reach through containers. Whether a constraint reaches through
Option<T>andList<T>to the element, or applies only to the type that declares it. - Authored
Baseor reflected. WhetherBasestays an authored associated type or is read fromReflectNewtype::Base. Reading it would make a newtype'sConstrainedimpl one line, but it would also bindConstrainedto newtypes and shut out the struct wrapper. - Where a failure points. Whether a failed check reports the field's
index, as
DeserializeStructdoes, or the value's span. - The base's schema, in the constrained blanket.
impl<T: Constrained + Reflect> JsonSchema for TreadsT::Base's shape, butConstrainedguarantees onlyBase: Deserialize, so the blanket does not type-check for every constrained type. A bound reaches a parameter, not a projection of one, soT::Base: JsonSchemahas no form in the grammar. - Which blanket owns a constrained composite. A struct wrapper that
implements
Constrainedsatisfies both that blanket and the structuralReflectStructone; Wado has no specialization and picks the first satisfied blanket, so the shape emitted is whichever registered first. Nothing names an owner, and registration order is not part of the design. - 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 — and no validation attribute at all. 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. A built-in attribute was the alternative, and it is not needed: a constraint is a property of a type, soConstrainedcarries it as an ordinary trait impl, which the boundary check and the emitted keywords both read. That leaves the compiler out of the vocabulary entirely, where an attribute would have made it own every recognized key.descriptioncomes from///doc comments. - Capability B is library code, not compiler magic. It is built on the
compile-time
ReflectStructintrospection, 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) has its linchpin:
ReflectStructand its three sibling traits are synthesized, the pack binding that carriesimpl<T: ReflectStruct<FieldTypes = [..F]>, ..F: JsonSchema>resolves, andcore:serdederives through both. What B still waits on is the two facts a schema needs that serde does not (see the companion WEP's known gaps):Member::doc()fordescription/title, andTypeInfofor a$defskey that separatesPair<String>fromPair<i32>. Assertion keywords wait onConstrainedinstead, which is Jade's own library work. Its originally-planned first consumer, a schema for Kiln generator options, no longer needs any of it: options cross the generator boundary as a typed WIT value, so their shape is read from the component's own WIT rather than from a derived schema document. - Capability C (validation) and D (codegen) are future work.
