Configuration
Governing ADR: ADR-0090 (application configuration). The model — the layered source-stack, one typed struct per subsystem, validation, and discovery — is stable and mostly shipped; the rollout is still settling at the edges (a handful of chassis-wide knobs are still read inline). This page documents the contract and defers the rollout's internals to the ADR.
Configuration is how a knob's value gets decided before the engine runs — the worker-pool size, an HTTP allowlist, the tick rate. (A secret such as an API key is never a knob value; config only names it — see Secrets are never knob values.) Values come from a layered stack of sources (defaults, environment, command-line arguments) with a defined precedence, declared once per knob and resolved the same way everywhere. And it's per-spawn: two substrates launched from one shell can be told apart — one with a capability enabled under key A, another with it off — the axis the "substrate as a general application host" direction needs.
When you drive the engine over MCP, configuration is per engine: you hand
spawn_substrate the arguments for one substrate and give a loaded component its
own typed config, independent of any other engine. When you author a capability or
component, you declare each knob once on its config struct — that single
declaration parses the value, supplies its default, validates it, and lists it
for discovery.
Why it exists
Aether is assembled from many subsystems — capabilities, the runtime, the chassis — each with its own knobs, and all of them are configured together at startup. A single configuration standard gives them one way to declare a knob and one way to resolve it, and three properties follow from that: validation (a typo'd key, or a known key set to garbage, is caught at boot rather than swallowed by a default), discovery (one listing of every knob, its default, and what it does), and no duplicated parsing across subsystems.
The second reason is per-spawn configuration. Configuration enters a process
once, at startup, and each channel addresses one audience (ADR-0162).
Environment variables configure the process a person launches directly — the
hub at a shell — and stop there: the hub and tunnel construct a forked child's
environment rather than handing down their own, clearing AETHER_* entirely
and copying only a shared allowlist of platform keys (locale, proxy, GPU and
audio driver families, PATH / HOME, and the like). Exporting an aether knob
on the hub therefore does not reach the substrates it forks. Per-spawn arguments
are what configure one engine differently from the rest — they ride the spawn
call to a single substrate as argv, the addressed machine channel — which is
what the "substrate as a general application host" direction needs.
The model: layered sources, one struct per subsystem
A resolved value comes from a stack of sources, lowest precedence to highest:
typed defaults < config file < environment < argv
Argv overrides the environment, the environment overrides a file, a file
overrides the declared default. The file is a sectioned TOML source supplied with
--config <PATH> or the AETHER_CONFIG_FILE fallback; if neither is set, an
engine boots exactly as env-then-argv configuration dictates.
Each subsystem owns its own config struct, in its own crate, declaring its own
knobs — there is no central registry that every subsystem has to register into.
"Its own crate" means the crate whose code receives the resolved value, not
whichever crate the knob is named after: WindowConfig is a desktop-chassis boot
knob group rather than the aether-window cap's config, because the chassis
driver reads it and no window actor does.
Capability module anatomy records where
a cap's config file sits and the one-config-per-actor bound that goes with it.
A #[derive(aether_substrate::Config)] on that struct is what unifies them: from
the field annotations it generates the environment parsing, the argument
(clap) layer, and the layered resolution, and a machine-readable description
of every knob that the discovery dump walks. So the declaration stays next to the
field that owns it, and parse, validate, and discover all come from that one
declaration.
Resolution, validation, and discovery
Resolution is strict. At boot the chassis warns on any AETHER_* variable
that no registered knob claims — catching a typo
without breaking on a stray CI variable — and hard-errors on a known key
that's set but fails to parse, rather than falling through to the default. A bad
value stops the boot with the key named, instead of a subsystem quietly running
on a default you didn't ask for.
Parsing failure is distinct from post-parse semantic validation. A known field with malformed syntax hard-errors. A successfully parsed value can still be rejected or normalized by a subsystem's explicit policy—for example a window mode may warn and fall back when the requested monitor mode is unavailable. Document that behavior with the owning subsystem rather than hiding it as a parse default.
Discovery is the --print-config flag on any chassis binary: it walks the same
declarations and prints every knob — its environment key, the value it resolves
to and which source that value came from, its default, and its doc — then exits
without booting. The walk is composition-derived (ADR-0156): composing a cap
with with_actor accumulates that cap's config member into the chassis's
aggregate, so the dump and the unknown-key sweep list exactly the knobs this
chassis wires and nothing else — headless doesn't "know" the window and audio
knobs it never composes. That listing is generated from the field annotations,
so it can't drift from what the engine actually reads. It's the first place to
look when you're unsure what a build will do with a given variable. The dump
ends with a SECRETS section: the --secrets-dir directory and each secret
file's name and status (set or invalid: <rule>), never a value.
Secrets are never knob values
A secret — an API key, a bearer token — is never a knob value, an argv flag, an
environment variable, or a kind field, because every one of those surfaces is
printed, inherited, journaled, or mailed somewhere
(ADR-0235).
Instead each secret is a named file in one directory, named by the
--secrets-dir <path> flag every chassis root carries (a systemd unit passes
--secrets-dir %d; there is no environment-variable form). Config holds only
the name: a capability declares a SecretRefs knob — a comma list of
<key>=<secret-name> — with the #[config(secrets)] field hint, and loads the
values once in init:
#![allow(unused)] fn main() { /// Names only; the values live in the secrets directory (ADR-0235). #[cfg(feature = "runtime")] #[cfg_attr(feature = "runtime", config(secrets))] pub secrets: SecretRefs, }
The hint wires the validating parser on the env, file, and argv sides and binds
the refs to the source stack's --secrets-dir when the member resolves;
config.secrets.load()? in init then yields the values as Secrets — redacted
Debug, read only through expose(), wiped on drop. Because the knob holds
names, its --print-config row is safe to print. HttpConfig.secrets
(--http-secrets) is the first consumer; the
Supplying secrets recipe walks the operator
side.
Configuring a running engine
Over MCP there are three ways to set configuration, from coarsest to finest:
- The environment configures the process you launch directly — the hub
itself, or a chassis you run at a shell. Use
--print-config, the owning config struct, and the active surface contract for exact knobs (AETHER_TICK_HZ,AETHER_SAVE_DIR,AETHER_AUDIO_OUTPUT,AETHER_ACTOR_TRACE_RING_SIZE, and the rest). It's fixed at launch and addressed to that one process:AETHER_*is scrubbed from a spawned engine's environment at fork (ADR-0162), so a knob exported on the hub does not reach the substrates it forks — configure those through per-spawn arguments below. - A chassis config file is the persistent per-deployment layer. Pass
--config path/to/chassis.tomlon the chassis command line, or setAETHER_CONFIG_FILEas a fallback for the file path. The file is sectioned by subsystem, and a section name is the config struct's owncli_prefix(or thesectionits derive pins):[http],[http-server],[process],[rpc],[lifecycle],[actor],[scheduler],[settlement],[registry],[chassis], plus chassis-specific sections such as[window],[audio],[render],[tick], the desktop driver's[desktop], and the hub's[hub]. Environment variables still override file values. - Per-spawn arguments are how a spawned engine is configured.
spawn_substrateforwards itsargsto the substrate as command-line arguments — the addressed machine channel (ADR-0162) — so you can spawn one engine with--tick-hz …or--http-disableand leave the next one alone. Argv is where each engine's knobs live, since the hub's environment does not cross into its children. Flag names are mechanical: take the environment key, drop theAETHER_prefix, lowercase, and hyphenate (AETHER_HTTP_TIMEOUT_MS→--http-timeout-ms). - Component config is finer still: a component declares a typed
Configand receives it atinit. Because a guest's config crosses the wasm boundary as bytes, that type is a kind (schema-bearing):describe_componentidentifies the config kind anddescribe_kindssurfaces its shape. Over MCP, pass eitherload_component.configas inline structured JSON orconfig_pathas a path to a JSON file; the harness schema-encodes that JSON to wire bytes before the byte-transparent chassis hands the decoded value to the guest'sinit. The two inputs are mutually exclusive, andconfig_pathis JSON rather than pre-encoded wire bytes. This mirrors a native actor exactly: both declaretype Configand receive it at construction (ADR-0090 §5). Boot config arrives atinit; runtime reconfiguration, if a component wants it, is ordinary mail — the same kind can serve both.
Adding a knob
Author-side, a new knob is a field on the subsystem's resolved-config struct, not
a fresh env::var read. Derive Config on the struct and annotate the field:
#![allow(unused)] fn main() { #[derive(Clone, Debug)] #[cfg_attr(feature = "runtime", derive(aether_substrate::Config))] #[cfg_attr(feature = "runtime", config(env_prefix = "AETHER_HTTP", cli_prefix = "http"))] pub struct HttpConfig { #[cfg_attr(feature = "runtime", config(default = false))] pub disabled: bool, #[cfg_attr(feature = "runtime", config(default = [], csv_set))] pub allowlist: HashSet<String>, } }
The derive emits the environment-shaped layer, the clap argument overlay, and
the from_env / from_argv_then_env resolvers. A numeric, Duration, or bool
field carries only its default — confique's native parsing trims the value,
treats an empty one as unset (falling back to the default), accepts the usual
bool spellings (1 / true / yes / 0 / false / no), and hard-errors on
a non-empty garbage value. The remaining field hints (env, cli_long,
ms_duration, csv_set, nonzero, and secrets for a
secret binding) carry the rest of the per-knob shape;
parse names a custom parser for the rare field that needs one. Two things to
know going in:
- Gate it on the
runtimefeature wherever the file itself reaches the wasm marker build, as above. A capability crate also cross-compiles to wasm, where the config machinery isn't available; the#[cfg_attr(feature = "runtime", …)]keeps that build carrying only the plain struct. A config file reached only through#[cfg(feature = "runtime")] mod runtime;is already gated once at the module and derives bare —aether-audio,aether-render, andaether-lifecycleare the live examples. Clippy runs host-native and won't catch a missing gate — the wasm32 cross-build in CI will. - Wire the argument overlay into the chassis CLI so the per-spawn layer
reaches your knob, and add a
*_defaults_matchtest (the derive's literal default and your struct'sDefaultare declared separately and a test keeps them honest).
The full walkthrough is the Adding a config knob recipe; the rule to carry is that a knob is declared once and resolved by the layer, never read ad-hoc.
A clippy.toml disallowed-methods entry bans std::env::var / std::env::var_os
workspace-wide to keep that rule mechanical: a capability that reads the
environment directly fails cargo clippy --workspace --all-targets --all-features -- -D warnings (the CI gate). A
legitimately external read — the config machinery
itself, a process-level tuning knob, a standard HOME / XDG lookup, a build
script, or test code — carries an #[allow(clippy::disallowed_methods)] with a
one-line reason stating why it is not cap config.
HttpConfig above is the live example: each field is a knob the deployer sets at
boot, and what those knobs gate — the deny-by-default egress allowlist, the body
cap, the per-request timeout — is the subject of HTTP egress.
Where to read more
- The rollout's design, the source-stack rationale, and the crate choice — ADR-0090.
- The
spawn_substratearguments andload_componentconfig inputs in their tool context — The MCP harness. - How a component declares and receives
type Config— Components & lifecycle. - The exact resolved knob inventory — the target chassis's
--print-configoutput and its current config structs.