Wado

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:

Wado is positioned to do better than either, because the pieces these ecosystems bolt on after the fact already exist as first-class infrastructure:

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:

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

JadeJSON Assertion & Definition Engine.

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:

#[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:

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" },
};

objectstruct (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:

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:

  1. A — schema model first. Pure library over core:serde / core:json / core:value; shippable immediately and the foundation every other layer builds on.
  2. 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.
  3. B — derivation is the only layer gated on the companion language WEP (Reflect synthesis, 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 a wasi: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

Costs and trade-offs

Open questions

Resolved during design

Implementation status