Schema codegen (config schema is generated from Rust)

June 30, 2026 · View on GitHub

The MXC config JSON Schema is generated from the Rust wire model, not hand-authored: one source of truth (Rust types), with the schema and (later) the SDK types as generated by-products that cannot drift from it.

Source of truth

src/core/wxc_common/src/wire.rs defines the wire model (MxcConfig and its nested types). It is precise by construction:

  • real enums for closed value sets (Containment, NetworkPolicy, Phase, …),
  • #[serde(rename_all = "camelCase")] for wire names,
  • #[serde(deny_unknown_fields)] on the stable surface → the generated schema is closed (additionalProperties: false),
  • the experimental block is intentionally permissive (no deny_unknown_fields): experimental features are in flux, so the schema documents the known shapes for editors without rejecting in-progress fields. Strict, closed contract = the stable (top-level) surface only.
  • /// doc-comments become schema descriptions.

Generating

cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- schemas/dev/mxc-config.schema.0.8.0-dev.json

mxc_schema_gen calls wxc_common::wire::generate_config_schema_json(), which runs schemars and post-processes the result to replace schemars' Rust-specific integer formats (uint32, …) — undefined in JSON Schema draft-07 — with standard constraints (minimum: 0 for unsigned). The schema-gen feature on wxc_common gates the schemars dependency so production builds don't carry it.

CI gates (Versioning Checks job)

  • check-schema-codegen.js — regenerates the schema and fails if the committed schemas/dev/...json differs (the schema can never go stale).
  • validate-configs.js — validates the tests/examples + tests/configs corpus against the committed schema.
  • check-schema-versions.js / check-version-sync.js — version-constant and product-version sync.

What the generated schema does NOT contain

Cross-field constraints — the single-backend-section rule and phase-scoping that the hand-written schema expressed with top-level allOf — are not in the generated schema. They are enforced by the parser (wxc_common::config_parser), which is the trust boundary. The schema is an editor/CI convenience, never the gate; the parser rejects a backend/containment mismatch regardless of what the schema says.

Equivalence to the previous hand-written schema

The generated schema replaced a hand-maintained one. Because the schema is a convenience and not the trust boundary, equivalence is judged behaviorally, not by diffing the JSON line-by-line (the encodings differ: the hand schema inlined every object, while schemars emits a definitions block with $ref indirection and wraps optionals as anyOf: [{ $ref }, { "type": "null" }], so the file roughly doubled in size with no change in meaning). Three lenses:

  1. Accept side — every config in the tests/examples + tests/configs corpus must still validate. The validate-configs.js gate enforces this.
  2. Reject side — the effective per-property constraints (allowed keys, enum value sets, additionalProperties open/closed, required) after resolving $refs.
  3. Delegation — constraints a JSON Schema expresses awkwardly are deliberately moved to the parser.

Comparing the generated schema against the prior hand-written one on lens (2):

  • Enums are identical on every canonical path (containment, network.defaultPolicy, network.enforcementMode, ui.clipboard, processContainer.ui.isolation, seatbelt.launchMethod, isolation_session.configurationId, port protocol).
  • The generated schema is stricter: it closes the stable nested objects (process, network, filesystem, lifecycle, ui, lxc, fallback, processContainer/.ui, seatbelt, the isolation user bundle) with additionalProperties: false, matching the wire model's deny_unknown_fields. The hand schema left several of these open, so the generated one catches nested typos the old one silently accepted.
  • The generated schema is more complete: it documents surface the hand schema omitted — processContainer.learningMode, experimental.windows_sandbox.idleTimeout (legacy alias), experimental.seatbelt (pre-promotion alias), and the per-phase isolation_session.{provision,start,stop,deprovision} nesting.

Two reductions are intentional, each compensated by the parser:

Dropped from the schemaWhy it's safe
Top-level allOf cross-field rules (single-backend-section; appContainer alias note)Semantic rules the parser enforces at runtime; the editor no longer pre-flags them, but a backend/containment mismatch is still rejected.
appContainer alias path is undocumented; network.proxy.builtinTestServer widened from const: true to booleanThe serde alias still parses, and convert_wire_proxy still rejects builtinTestServer: false.

Root metadata ($id, title, description) is preserved: title comes from a #[schemars(title = …)] attribute on MxcConfig, description from its doc comment, and $id is injected in the post-process step of generate_config_schema_json (schemars does not emit one).

Net: the generated schema is equivalent-or-stricter on values and structure, more complete in coverage, and less expressive only on the cross-field rules — gaps consciously owned by the parser. The equivalence is not a one-time review: the codegen gate regenerates the schema from the types on every CI run, and the corpus gate pins the accept-side behavior.

Generated SDK types (drift oracle, Rust emitter)

The SDK's wire TypeScript types are generated too — by a Rust emitter, with no third-party generator. mxc_schema_gen --ts walks the same generated schema value and wxc_common::ts_emit emits sdk/src/generated/wire.ts. That file is not public API — it is a drift oracle. The unit test sdk/tests/unit/wire-conformance.test.ts asserts (at tsc time) that the hand-written public types in sdk/src/types.ts still conform to it, and check-sdk-types-codegen.js is a CI gate (running the emitter and diffing the committed file) that fails on drift. So a wire-model change ripples to all three surfaces — Rust ⇄ schema ⇄ TS — and a forgotten SDK update fails CI instead of drifting silently. The emitter handles only the JSON Schema constructs the MXC schema uses (enums, closed/open objects, $ref, anyOf [T, null], arrays, scalars); extending the wire model with a new construct may require teaching the emitter about it.

The conformance check covers both SDK surfaces: wire-conformance.test.ts pins the one-shot public types in sdk/src/types.ts, and wire-conformance-state-aware.test.ts pins the state-aware lifecycle types in sdk/src/state-aware-types.ts (the Phase and sizing-profile enums, the Entra user bundle, and the per-phase IsolationSessionPhase field set) against the same generated wire defs. Both share the assertion helpers in sdk/tests/unit/conformance-helpers.ts and check drift in both directions (public→wire and wire→public) so a new wire field the SDK forgets to expose also fails the build.

Why a hand-written emitter (alternatives considered)

The generated wire.ts is a drift oracle, not the public API. The public SDK types (sdk/src/types.ts, sdk/src/state-aware-types.ts) stay hand-written, and the conformance test asserts they match the oracle. Two other approaches were evaluated and rejected:

  • Generate the public API directly (generate-and-replace). The public types are a curated surface a raw generator can't reproduce: JSDoc, the branded SandboxId<C>, the IsolationSessionUserConfig class (token redaction), and a per-call-phase organization that deliberately does not map 1:1 to the wire defs. Replacing them from a generator would either ship an un-ergonomic API or get hand-massaged anyway, and would churn the public surface (and its review diffs) on every wire tweak. The oracle gives identical, CI-enforced, bidirectional drift safety without coupling the public ergonomics to generator output.
  • A third-party schema→TS generator (e.g. json-schema-to-typescript). It pulls ~15 transitive npm dependencies onto the public MxcDependencies feed, where new transitive packages 401 until manually seeded — a recurring CI/ supply-chain cost. The in-repo emitter is a few hundred lines, has zero dependencies, and handles exactly the constructs our schema uses, giving exact control over the output so the conformance comparison stays precise.

Either alternative could revisit the "devs run a script and check in" workflow, but that workflow already exists here (mxc_schema_gen --ts, enforced by the codegen gate) — only the oracle is generated, not the curated public types. A larger move (e.g. describing the config in a FlatBuffers IDL to emit both Rust and TS) would replace the JSON config contract itself and trade away human-authorable config files and $schema editor validation; out of scope here.

Roadmap

  • The wire model generates the committed dev schema, guarded by the codegen and corpus CI gates.
  • The parser deserializes directly into the wire model and the Raw* structs are gone, so the schema source and the trust boundary share one definition of the wire shape and cannot drift.
  • The SDK TypeScript wire types are generated from the same wire model (sdk/src/generated/wire.ts, via the wxc_common::ts_emit Rust emitter), guarded by a conformance test plus the check-sdk-types-codegen.js gate, and the hand-maintained *-strict.json stable view has been retired.