MXC Versioning Design
August 25, 2026 · View on GitHub
Core Concepts
Policy = Intent
The policy (filesystem, network) expresses what the user wants — "block network, allow these paths." It does not specify how the OS enforces it, nor which container type to use.
Policy Version = Config Schema Version
The version field in SandboxPolicy must match the MXC config
JSON version: they are the same version, tied 1:1.
When a consumer specifies a SandboxPolicy version (e.g.,
0.6.0-alpha), MXC creates the corresponding configuration using the
0.6.0-alpha schema.
// sdk/node/src/types.ts
const policy: SandboxPolicy = {
version: "0.6.0-alpha",
filesystem: { ... },
network: { ... },
timeoutMs: 30000,
};
The config JSON carries this same version:
{
"version": "0.6.0-alpha",
"process": { ... },
"filesystem": { ... },
"network": { ... }
}
Versioning follows Semver
Per semver.org:
- Patch (x.y.Z) — bug fixes only
- Minor (x.Y.0) — new features, backward compatible
- Major (X.0.0) — breaking changes
The three version axes
MXC tracks three independent "versions" that are deliberately never conflated. Each answers a different question and changes for different reasons:
| Axis | What it describes | Where it lives | Who decides it |
|---|---|---|---|
| Schema (config) version | The shape of the config JSON — which fields exist and what values they accept. | The version field in the config / SandboxPolicy. | The config author. |
| Product version | The MXC binaries and npm package that do the work. | Rust workspace version (src/Cargo.toml) + sdk/package.json. | The release. |
| Host capability | What the running OS can actually enforce (e.g. whether the BaseContainer sandbox API is usable, velocity keys, Hyper-V). | Negotiated at runtime — never a string in the config. | The host, probed at execution time. |
- Schema version is semver and is checked at the trust boundary: the parser
accepts the
0.6.xfloor line through the0.9.xdev-ceiling line (the canonical min/max constants are currently0.6.0-alphaand0.9.0-alphainschemas/schema-version.json), and the SDK mirrors that range. Onlymajor.minoris compared — patch and pre-release labels are ignored — and both back-dated and forward-dated versions are rejected. - Product version tracks the shipped artifacts and moves independently of the
schema version; a binary release can fix bugs without changing the config shape.
scripts/check-version-sync.jskeeps the Rust workspace and npm versions in step, andscripts/versioning/check-schema-versions.jskeeps the schema-version constants in step — but the two axes are not tied to each other. - Host capability is resolved by runtime negotiation, not by a version string.
As of Phase 3a the schema
versionno longer selects the Windows backend: ProcessContainer resolves to BaseContainer or AppContainer purely by host capability (see Version Negotiation). An identical config runs the same way regardless of which (in-range) schema version it declares.
Schema Shipping Model
mxc/schemas/
├── stable/
│ ├── mxc-config.schema.0.4.0-alpha.json (retired — below the supported floor)
│ ├── mxc-config.schema.0.5.0-alpha.json (retired — below the supported floor)
│ ├── mxc-config.schema.0.6.0-alpha.json (minimum supported)
│ ├── mxc-config.schema.0.7.0-alpha.json (shipped)
│ └── mxc-config.schema.0.8.0-alpha.json (shipped — current stable)
└── dev/
├── mxc-config.schema.0.9.0-dev.json (rolling parser — currently authoritative)
└── mxc-config.schema.0.9.0-alpha.json (exact closed contract — future authority)
Retired stable schema files are kept as immutable historical artifacts — the
parser simply stops accepting those versions (the supported floor is
0.6.0-alpha). Released schemas are never edited or deleted.
The two development schemas coexist during the version-specific parser transition:
mxc-config.schema.0.9.0-dev.jsonis generated from the rollingwxc_common::wiremodel. It remains authoritative for runtime parsing and corpus validation until exact dispatch is enabled.mxc-config.schema.0.9.0-alpha.jsonis generated from the exactmxc_config_contract::devmodel. It describes all eight closed one-shot and state-aware roots, including recursively closed experimental structures, but does not become authoritative until exact dispatch replaces the rolling parser.
Both files are generated development artifacts rather than released schemas. See Schema Code Generation for their regeneration commands and independent drift gates.
Trust boundary vs schema defaults
Schemas in stable/ are immutable: they document the input shape that was
promised at release. They are not authoritative for runtime security
defaults. wxc-exec is the trust boundary and may apply stricter defaults
than a stable schema declares when a security issue requires it.
For example, an older stable schema may declare
network.defaultPolicy defaulting to "allow". The runtime may treat an
absent network.defaultPolicy as block regardless of the declared schema
version when the old default is a security bug. The older stable schema is
left unchanged so the release contract stays auditable; newer schemas
document the corrected default. Consumers that need the legacy behavior
must set the field explicitly.
Shipped vs Experimental
Each experimental feature is a typed property under experimental — the same
pattern as stable features (filesystem, network) under the top-level
config. This gives editors full autocomplete and validation for experimental
configs. Today, the --experimental flag is a global toggle that enables all
experimental features; per-feature gating (e.g., --experimental compartments)
is under consideration.
Rules:
- Stable section (top) — shipped, stable, supported. Always executed.
- Experimental section — an object containing experimental features as typed properties, only applied when the experimental flag is enabled (see below). Each feature defines its own schema. As long as experimental code doesn't break what is shipped, developers are free to iterate.
- Promotion: When an experimental feature is ready to ship, move it from
experimentalto the top-level section and bump the minor version.
Experimental Flag
The experimental flag must be supported at every layer of the stack:
1. wxc-exec.exe / lxc-exec (Rust binaries):
wxc-exec.exe config.json --experimental
lxc-exec config.json --experimental
# Flag order does not matter — these are equivalent:
wxc-exec.exe --experimental config.json
The parser always parses and preserves the experimental section regardless
of the flag; parsing is flag-independent. The --experimental flag only sets
request.experimental_enabled:
- When set, the runners apply the parsed experimental features alongside the stable features
- When unset,
experimental_enabledis false and the runners ignore the parsed experimental section — no error, the features are just not applied
2. SDK (@microsoft/mxc-sdk):
// With policy:
const pty = spawnSandbox("python app.py", policy, {
experimental: true,
debug: false
});
// Or with config:
const config = createConfigFromPolicy(policy, "process");
config.process!.commandLine = "python app.py";
const pty = spawnSandboxFromConfig(config, {
experimental: true,
debug: false,
});
The SDK passes --experimental to the underlying binary when this option is set.
Forking Code for Experimental Features
Developers adding experimental features follow this pattern. For a detailed step-by-step guide, see Authoring a New Feature.
In wire.rs (the parse + schema source of truth):
pub struct MxcConfig {
// ... stable fields ...
pub experimental: Option<Experimental>,
}
// The `experimental` block is intentionally permissive (no deny_unknown_fields)
// so in-flux feature shapes stay forward-compatible.
pub struct Experimental {
pub compartments: Option<Compartments>,
pub gpu_isolation: Option<GpuIsolation>,
// ... add new experimental features here ...
}
During the exact-parser transition, edit both the rolling wire.rs model used
by the current parser and the closed mutable contract under
src/core/mxc_config_contract/src/dev/. Regenerate both schemas:
cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- schema --legacy-wire --out schemas/dev/mxc-config.schema.0.9.0-dev.json
cargo run --manifest-path src/Cargo.toml -p mxc_schema_gen -- schema --version 0.9.0-alpha --out schemas/dev/mxc-config.schema.0.9.0-alpha.json
Also regenerate their TypeScript oracles with the corresponding
mxc_schema_gen types commands. Do not hand-edit generated artifacts.
In models.rs:
pub struct ExperimentalConfig {
pub compartments: Option<CompartmentsConfig>,
pub gpu_isolation: Option<GpuIsolationConfig>,
}
pub struct ExecutionRequest {
// ... stable fields ...
pub experimental_enabled: bool, // set by --experimental flag
pub experimental: ExperimentalConfig,
}
In config_parser.rs: map the wire Experimental field to the domain
ExperimentalConfig inside convert_wire_config (there is no Raw* struct).
In the runner (e.g., appcontainer.rs):
fn run(&mut self, request: &ExecutionRequest, logger: &mut Logger) -> ScriptResponse {
// ... normal execution ...
// Experimental features only applied when flag is set
if request.experimental_enabled {
if let Some(ref compartments) = request.experimental.compartments {
self.apply_compartments(compartments, logger);
}
if let Some(ref gpu) = request.experimental.gpu_isolation {
self.apply_gpu_isolation(gpu, logger);
}
}
}
Promotion process: When an experimental feature is ready to ship:
-
Move the field from the wire
Experimentalstruct to top-levelMxcConfig(e.g.,experimental.gpuIsolation→ top-levelgpuIsolation), then regenerate the schema withmxc_schema_gen -
Move the struct from
ExperimentalConfigtoExecutionRequest -
Map the now-top-level wire field in
convert_wire_config; adddeny_unknown_fieldsto the wire struct so the promoted stable surface is closed -
Remove the
if request.experimental_enabledguard -
Bump the minor version
-
Add a parser error for configs still referencing the feature under
experimental:"<feature> has moved to the stable section". This error should persist for at least one release cycle so users have time to migrate, then it can be relaxed to the standard "unknown field" behavior. -
If the feature is a containment backend with a per-backend config section, update the single-backend-section enforcement when it graduates from experimental to top-level:
- In
wxc_common::config_parser, rename the matching entry inpresent_backend_sections(and updatevalidate_single_backend_section) fromexperimental.<name>to<name>.
The single-backend-section rule is a cross-field constraint enforced by the parser (the trust boundary), not by the JSON schema — the generated schema intentionally omits the old top-level
allOfif/thenclauses. So there is no schema edit for this step; the parser change is sufficient.The rule itself is unchanged: a backend section requires
containmentto be set, and the value must be either the concrete backend name or any abstract intent that resolves to it on at least one platform (for example,processContaineraccepts bothprocesscontainerandprocess). - In
Data Flow
User writes SandboxPolicy (policy + environment, versioned)
│
▼
Config JSON (version: "0.6.0-alpha")
│
▼
MXC parses → Stage 1: validate schema version (range check)
│ → if --experimental, includes experimental section
│
▼
Stage 2: resolve `containment` intent → concrete backend
│
▼
Stage 3: probe host capability → select backend tier
│ (ProcessContainer: BaseContainer if usable, else AppContainer)
│
▼
For the BaseContainer tier: translate policy → flat buffer
│ (fixed SANDBOX_SPEC_VERSION)
│
▼
Launch (Experimental_CreateProcessInSandbox(flatbuffer))
│
▼
Process runs in sandbox
Wire Model vs Runtime Model
MXC deliberately keeps two Rust representations of a config with a mapping at the parse boundary, rather than one shared type:
- Wire model (
wxc_common::wire::MxcConfig) — a faithful 1:1 mirror of the config JSON: every fieldOption,camelCase,experimentalcarried as a rawserde_json::Value, no invariants enforced. It is the parser's deserialization target and the single source of truth the JSON schema (via schemars) and the SDK TypeScript types are generated from. - Runtime / domain model (
models::ExecutionRequestand friends) — the validated, defaults-applied, invariant-rich model the backends consume: abstract containment resolved to a concrete backend,process.commandLinereshaped toscript_code, enums resolved to domain enums, required fields no longerOption.
The parser (config_parser) is the one validate/normalize boundary between them;
trivial enum/struct conversions are From impls beside the domain type, and the
larger reshaping lives in convert_wire_config.
Why two layers (pros)
- One validate/normalize boundary. Defaults, invariant enforcement, abstract→concrete backend resolution, and field reshaping all happen in exactly one place; backends receive a type whose invariants already hold.
- Parse, don't validate. The domain type makes illegal states
unrepresentable (required fields non-
Option, enums resolved, containment always concrete), so a backend never re-checks "is this set / known?". - The wire model stays a pure schema/DTO source. Being exactly the JSON shape is what makes schemars-from-types and SDK TS codegen clean — and it is what the per-field stability attributes (stable/experimental/deprecated, for the stable-vs-dev schema views and the promotion guard) hang on. A merged type would entangle schema-generation concerns with runtime fields.
- Decoupled evolution. The wire format can change (rename, alias, restructure
experimental) without touching backend code, and vice-versa; the blast radius of either is bounded by the parser. - Backends don't couple to JSON quirks — camelCase renames, deprecated-spelling
serde aliases, the raw-
Valueexperimental block,$schema/_commentpassthrough — none leak into runner code.
Costs (cons)
- Boilerplate. Two definitions plus a mapping for each object; adding a field
touches the wire struct, the domain struct, and the parser (
Fromimpls only soften the trivial cases). - Internal drift risk. The two Rust types can fall out of sync. This is
mitigated by destructuring wire structs without
..in conversions (a new wire field then fails to compile until mapped), but that is a convention, not a guarantee everywhere. - Indirection. Tracing one field means hopping wire struct → mapping → domain struct → runner.
Why the split is the right call for MXC
It earns its keep because of three load-bearing facts: (a) the wire model is also the schema + SDK codegen source, a job that wants a pure JSON-shaped type; (b) there is genuine wire↔runtime impedance (containment resolution, field reshaping, defaults, deprecated-spelling aliases) that must live somewhere, and concentrating it in the parser beats scattering it across backends; (c) the per-field stability attributes need the wire model as a distinct annotatable layer. For a config that was a thin pass-through, a single layer would be the better call — here it is not.
The real costs (boilerplate, internal drift) are addressable without merging
— e.g. a derive/macro for the trivial wire→domain From impls, or a compile-time
totality check on the mapping — which captures most of the single-layer ergonomics
while preserving the separation the schema/SDK codegen and stability-attribute work
depend on. No planned phase merges the two models; 2B already reduced three layers
(Raw* → … → domain) to two (wire → domain), and a single layer is explicitly not
on the roadmap.
Version Negotiation
Execution resolves a request in three ordered stages. The schema version gates only the first; it does not influence stages 2 or 3 (Phase 3a removed that coupling).
Stage 1 — Schema-range check (the trust boundary, `config_parser`)
Is config.version within [floor, dev-ceiling]? (major.minor; pre-release
labels ignored)
below floor → error: "older than supported" (update your config)
above ceiling → error: "newer than supported" (upgrade wxc-exec)
in range / absent → continue
Stage 2 — Containment resolve (independent of schema version)
Map the `containment` intent to a concrete backend:
omitted / "process" → OS-native process sandbox
(Windows: ProcessContainer, Linux: Bubblewrap,
macOS: Seatbelt)
"vm" → host VM-class backend
explicit backend → used verbatim
Stage 3 — Host-capability negotiate (runtime probe, no version input)
For ProcessContainer on Windows:
BaseContainer usable on this host? (is_base_container_usable())
yes → BaseContainer (native OS sandbox API)
no → AppContainer fallback tier (BFS when compiled in with the
`tier2_bfs` feature and `bfscfg.exe` is present, else DACL)
The chosen tier and any fallback are logged (warnings + "selected isolation
tier: …"). This capability fallback is the ONLY fallback.
For the BaseContainer tier, Stage 3 translates the policy into a FlatBuffer and
invokes the OS sandbox API. Today MXC builds the FlatBuffer at a fixed spec
version (SANDBOX_SPEC_VERSION, base_container_runner.rs) and calls
Experimental_CreateProcessInSandbox directly:
translate policy → FlatBuffer (fixed SANDBOX_SPEC_VERSION)
→ Experimental_CreateProcessInSandbox(flatbuffer) → success or typed error
Forward-looking: the design anticipates a spec-version handshake — the OS advertising the spec versions it supports (
EnumerateSandboxSpecVersionInfo) and MXC selecting the best one for the policy's features before translating — so that a single binary can target multiple OS sandbox revisions. That enumerate/select step is not implemented yet; the current code uses the fixed spec version above.
Backend selection is capability-driven, not version-driven (Stage 3 takes no version input), and security policy never fuzzy-falls-back: if the selected backend cannot honor the requested filesystem/network policy, execution fails with a typed, actionable error rather than silently weakening enforcement (see Error Contract).
OS APIs
The BaseContainer tier calls the OS sandbox API to launch the child:
// Execute with the translated policy (current).
HRESULT Experimental_CreateProcessInSandbox(
BYTE* flatbuffer,
UINT32 flatbufferSize,
PROCESS_INFORMATION* processInfo
);
// Forward-looking (not yet implemented): query the spec versions the OS
// supports so a single binary can target multiple sandbox revisions.
HRESULT EnumerateSandboxSpecVersionInfo(
UINT32 highestMajor,
SANDBOX_VERSION_INFO** versions,
UINT32* count
);
Error Contract
Negotiation failures are typed and actionable — never a silent fallback:
- JSON and policy-shape failures distinguish malformed JSON syntax from
valid JSON that does not match the typed wire contract. Typed failures name
the full policy path (for example,
network.proxy.localhost), retain Serde's expected type/value information, and include source line/column when parsing directly from request text. State-aware per-backend configuration errors are prefixed with their fullexperimental.<backend>.<phase>location. Diagnostic text escapes control characters, and errors at secret-bearing paths redact the submitted value. After the root JSON value, only whitespace is accepted; trailing JSON values or other trailing content are rejected as malformed syntax. - Schema-range failures (Stage 1) carry a clear "older than supported" /
"newer than supported" message telling the caller whether to update the config
or upgrade
wxc-exec. - Capability failures (Stage 3) surface on the runner's
ScriptResponse(and the SDKspawnpath'sMxcError) as aBackendUnavailablefailure phase when the requested backend's API is absent (e.g. the BaseContainer OS sandbox API is not present on this build), with a hint pointing at the host requirement — not a downgrade to a weaker backend behind the caller's back. (BaseContainer-vs-AppContainer is the one exception, and it is an explicit, logged capability tier, not a security relaxation.) - Policy-unsupported failures (a backend that cannot honor a specific policy
field, e.g.
deniedPaths) fail with a specific message naming the unsupported field. Security policy is deterministic — no relaxation, no fuzzy fallback.
The MXC ↔ OS contract therefore reports: which feature failed, whether it was a version mismatch or a runtime/capability unavailability (e.g. Hyper-V off), and what the user should do (upgrade OS, enable feature, change the config).
Experimental Features — Clarifications
Shipping model: The shipped schema contains only non-experimental
features. Experimental features exist solely for internal development and
testing — they are never shipped to end users. The --experimental flag is a
development tool, not a production feature.
Global flag: The --experimental flag is a single global toggle. When enabled,
all experimental features in the config are active. There is no per-feature
enable/disable mechanism — simplicity over granularity.
Migration after promotion: When an experimental feature is promoted to the
stable section (moved from experimental.X to top-level X in a stable
schema), configs that still reference it under experimental will receive
an error: "feature X has moved to the stable section." The parser will not
silently fall back — explicit migration is required.
Deprecation Aliases
When a wire value is renamed (e.g. appcontainer → processcontainer in
#268), the legacy spelling enters a
deprecation window where both forms are accepted on the wire.
Policy: deprecation aliases are version-agnostic. The native parser
accepts the legacy form regardless of config.version, and the SDK validator
mirrors that behavior. We do not gate alias acceptance on schema version (i.e.
"appcontainer only allowed for 0.4.0-alpha/0.5.0-alpha") because:
- Two layers must agree. Schema-version gating would mean a config accepted
by the binary could be rejected by the SDK validator (or vice versa) based
on a string in
config.version. That class of "works through one entry point but not another" bug is exactly what #390 surfaced. - Authors don't always control
config.version. Configs flowing from external sources (governance services, third-party tooling) may legitimately declare0.6.0-alphawhile still using legacy vocabulary their generator has not yet been updated for. - The deprecation window is short. The stated intent at rename time is removal in a future minor release; gating buys little and costs review complexity in every layer that re-checks containment.
Observability. Legacy value aliases are mapped to their canonical form by
serde (#[serde(alias = "...")]) during deserialization, so the Rust parser no
longer emits a per-value deprecation hint for them — serde normalizes the alias
before any parser code runs, and the wire model is the trust boundary. Aliases
are still accepted; they are simply silent in the native parser. The TypeScript
SDK validator may still surface a deprecation hint via diagLog where it
inspects the raw config before serialization. (Earlier revisions emitted a
Logger line from the hand-written parser for each legacy value; that path was
removed when the parser was rewired onto the wire model.)
Removal. When an alias is removed in a future release, the change goes
through the same promotion-style migration: a single release that turns the
silent accept-and-warn into an explicit unsupported_containment error.
Document the removal in the schema bump that drops it.
Open Questions
-
Experimental features on the OS side: Does
EnumerateSandboxSpecVersionInfodistinguish between stable and experimental OS capabilities? If the OS itself has experimental features, how does MXC discover and target them? -
Security of the experimental flag: Should
--experimentalrequire additional privilege or be restricted to debug builds? A malicious caller could pass--experimentalto enable a feature that weakens the sandbox boundary. -
Conflicting experimental features: If two experimental features have conflicting requirements (e.g., one denies a namespace, another relaxes it), how are conflicts resolved? First-wins, last-wins, or error?
-
Per-feature vs global experimental flag: Should
--experimentalbe a global toggle (all experimental features on/off), or per-feature (--experimental compartments --experimental gpu-isolation)? Per-feature gives more control but adds complexity to the executor and SDK interfaces. -
Shipping experimental features to customers: Should experimental features be shippable to specific customers (e.g., Anthropic, Nemoclaw), or strictly internal development only? If shippable, the security and stability requirements for experimental features increase significantly. What is the delivery mechanism — private npm package drop, feature-flagged public release, or a separate experimental binary?
-
Multiple dev schemas for multiple major versions: When multiple major versions are alive simultaneously (e.g., v1 shipped on OS 26100, v2 shipped on OS 27000, v3 in development), promoting a feature may require adding it to multiple schemas. For example, if "compartments" is additive, it should be added to both
dev/1.vnext.jsonanddev/2.vnext.jsonas a minor bump for each. If it's breaking, it goes only intodev/3.vnext.json. Thedev/folder and promotion process need to support this multi-schema model. Today we are pre-1.0 with only one major version, so a single dev schema suffices. -
Experimental features modifying stable behavior: Experimental features may need to modify stable behavior. How do we reason about and test this?