ADR-0009: Version 2 configuration only, held as a JSON value and validated against an embedded schema
Status: accepted · Date: 2026-10-13 · Decision makers: James Hebden
Context and Problem Statement
The Go tool moved from a v1 to a v2 configuration format (repositories, deployers, reloaders) and kept migration code. The Rust port had to decide which versions to load, how to represent a config that is merged from several sources (a base file, a local overlay, extends: fragments, and project files from cloned repositories), and where validation happens relative to variable expansion.
Decision Drivers
- Deep-merging YAML from several sources, with some keys appending rather than replacing
- Rejecting bad config with errors that point at the field
- Schema as the single source of truth (it also drives the generated reference docs)
- Don’t carry a v1 loader nobody needs
Considered Options
- Parse into
serde_json::Value, merge with RFC 7386-style rules plus append-list keys, expand variables, validate against an embedded draft-07 JSON Schema, then deserialise into typed structs; v2 only - Deserialise straight into typed structs and merge the structs
- Keep v1 loading and run the migration at load time
Decision Outcome
Chosen option: “JSON value, embedded schema, v2 only”, because merging untyped values is simpler than merging typed structs with optional fields, and the same schema file that the Go tool ships (schema/config.v2.schema.json, schema/caproni-project.schema.json) can be embedded with include_str!. config migrate exists but can only report “already latest”, because only v2 exists.
Variable expansion is hand-written rather than a shell. In configuration values $VAR, ${VAR}, length, indirection, slices, replacement, and prefix and suffix removal are supported, and the :-, :?, :+, and := forms are rejected as unsupported. Inside a variables: definition ${VAR:-default}, ${VAR:?message}, and ${VAR:+alternate} are also supported. $(...), backticks, and arithmetic are preserved verbatim and never evaluated. Remote extends: fragments must be https and named xianyun*.yaml or caproni*.yaml, capped at 10 MiB.
Consequences
Good:
- One schema, shared with the Go tool and the docs
- No command substitution in config values
Bad:
- Expansion falls back to any process environment variable, and
config printshows the result (measured 2026-10-13) (TM-CFG-003) - Remote fragments have no pinning or signature, and relative references inside them may accept
http://(TM-CFG-001, TM-CFG-002) - A project file from a cloned edit-mode repository can add hooks (TM-CFG-004)
- Schema validation runs after expansion, and the schema is recompiled on every load
serde_yamlis deprecated, and alias expansion is unbounded (TM-SUP-002, TM-CFG-006)- The schema text still says the default static name is
caproni
Neutral:
- The persistent file cache for remote fragments is ported but not wired in, so
--no-cacheis currently a no-op
Implementation Location
crates/xianyun-core/src/config/caproni.rscrates/xianyun-core/src/config/merge.rscrates/xianyun-core/src/variables/expand.rscrates/xianyun-core/src/variables/resolver.rsschema/config.v2.schema.json