MXC Versioning Design
June 12, 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.4.0), MXC creates the corresponding configuration using the
0.4.0 schema.
// sdk/src/types.ts
const policy: SandboxPolicy = {
version: "0.4.0-alpha",
filesystem: { ... },
network: { ... },
timeoutMs: 30000,
};
The config JSON carries this same version:
{
"version": "0.4.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
Schema Shipping Model
mxc/schemas/
├── stable/
│ ├── mxc-config.schema.0.4.0-alpha.json
│ ├── mxc-config.schema.0.5.0-alpha.json
│ ├── mxc-config.schema.0.6.0-alpha.json
│ └── mxc-config.schema.0.7.0-alpha.json (shipped — current stable)
└── dev/
└── mxc-config.schema.0.8.0-dev.json (current — work in progress)
The dev schema file (mxc-config.schema.X.Y.Z-dev.json) must define the experimental
section structure so that editors can validate experimental configs.
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
When --experimental is passed:
- The parser reads the
experimentalsection from the config JSON - Features from the experimental section are applied alongside the stable features
- Without the flag, the
experimentalsection is silently ignored — no error, just skipped
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 config_parser.rs:
struct RawConfig {
// ... stable fields ...
experimental: Option<RawExperimental>,
}
struct RawExperimental {
compartments: Option<RawCompartments>,
#[serde(rename = "gpuIsolation")]
gpu_isolation: Option<RawGpuIsolation>,
// ... add new experimental features here ...
}
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 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 property from
experimentalto a top-level property in the schema (e.g.,experimental.gpuIsolation→gpuIsolation) -
Move the struct from
ExperimentalConfigtoExecutionRequest -
Move the field from
RawExperimentaltoRawConfig -
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_sectionsandowned_backend_sectionfromexperimental.<name>to<name>. - In the JSON schema's top-level
allOf, rekey the matchingif/thenclause so it checks the new top-level section instead ofexperimental.<name>.
Concretely, if
wslcgraduates the clause changes from{ "if": { "required": ["experimental"], "properties": { "experimental": { "required": ["wslc"] } } }, "then": { "required": ["containment"], "properties": { "containment": { "enum": ["wslc"] } } } }to
{ "if": { "required": ["wslc"] }, "then": { "required": ["containment"], "properties": { "containment": { "enum": ["wslc"] } } } }The
thenbranch is unchanged: a backend section requirescontainmentto 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.4.0-alpha")
│
▼
MXC parses → validates schema version
│ → if --experimental, includes experimental section
│
▼
MXC calls OS: EnumerateSandboxSpecVersionInfo()
│ → returns supported tech language versions
│ e.g., [1.4.5, 2.0.0]
│
▼
MXC translates policy → flat buffer
│ (based on the tech language version the OS supports)
│
▼
MXC calls OS: CreateProcessInSandbox(flatbuffer)
│
▼
Process runs in sandbox
Version Negotiation
1. User sends SandboxPolicy with version "0.4.0-alpha"
2. MXC validates: is "0.4.0-alpha" ≤ SUPPORTED_VERSION?
Yes → continue
No → error: "upgrade wxc-exec"
3. MXC calls: EnumerateSandboxSpecVersionInfo(HIGHEST_MAJOR)
OS returns: [
{ version: "1.4.5", isAvailable: true },
{ version: "2.0.0", isAvailable: true }
]
4. MXC selects the best tech language version for the
features in the policy
5. MXC translates policy → flat buffer targeting that
tech language version
6. MXC calls: CreateProcessInSandbox(flatbuffer)
OS returns: success or error with disposition
OS APIs
// Query what the OS supports
HRESULT EnumerateSandboxSpecVersionInfo(
UINT32 highestMajor,
SANDBOX_VERSION_INFO** versions,
UINT32* count
);
// Execute with the translated policy
HRESULT CreateProcessInSandbox(
BYTE* flatbuffer,
UINT32 flatbufferSize,
PROCESS_INFORMATION* processInfo
);
Error Contract
MXC ↔ OS needs a defined error contract:
- Which feature failed
- Whether it's a version mismatch or runtime unavailability (e.g., Hyper-V off)
- What the user should do (upgrade OS, enable feature, etc.)
- Security policy is deterministic — no relaxation, no fuzzy fallback
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. Each legacy-value encounter emits a one-line deprecation hint
via the existing diagnostic channel (Rust: Logger; TypeScript SDK: diagLog,
dedup'd per legacy value per process). The hint names the canonical replacement.
No throw, no stderr write — the deprecation is observable only to callers who
opt into the diagnostic stream.
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?