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
experimentalblock is intentionally permissive (nodeny_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 schemadescriptions.
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 committedschemas/dev/...jsondiffers (the schema can never go stale).validate-configs.js— validates thetests/examples+tests/configscorpus 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:
- Accept side — every config in the
tests/examples+tests/configscorpus must still validate. Thevalidate-configs.jsgate enforces this. - Reject side — the effective per-property constraints (allowed keys,
enum value sets,
additionalPropertiesopen/closed,required) after resolving$refs. - 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, portprotocol). - The generated schema is stricter: it closes the stable nested objects
(
process,network,filesystem,lifecycle,ui,lxc,fallback,processContainer/.ui,seatbelt, the isolationuserbundle) withadditionalProperties: false, matching the wire model'sdeny_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-phaseisolation_session.{provision,start,stop,deprovision}nesting.
Two reductions are intentional, each compensated by the parser:
| Dropped from the schema | Why 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 boolean | The 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>, theIsolationSessionUserConfigclass (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 publicMxcDependenciesfeed, 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 thewxc_common::ts_emitRust emitter), guarded by a conformance test plus thecheck-sdk-types-codegen.jsgate, and the hand-maintained*-strict.jsonstable view has been retired.