Adding a config knob
Prereq (recompile class): you're editing aether's Rust and rebuilding, so you need
cargo; CI runs the full check set on every push. The Configuration explainer states the model this recipe walks; ADR-0090 holds the design. Read the explainer first if "layered source-stack" and "discovery dump" aren't already familiar.
A knob is a field on a subsystem's resolved-config struct, declared once with a
#[config(...)] hint that supplies its default and its env/CLI names. That
single declaration generates the partial layer used by the config file and env,
the clap argument overlay, the layered resolver, and the --print-config
discovery entry — so you never write an env::var(...).parse() read. This recipe
adds a knob end to end, with
the two gotchas (the runtime feature gate, the *_defaults_match test) inline at
the step where each bites.
The exemplar to copy
Follow HttpConfig
in crates/aether-http/src/client/config.rs. It's the same struct the
Configuration explainer excerpts, it carries most
of the hints you'll reach for (default, env, cli_long, csv_set,
ms_duration, layer_field), and it's wired into both full-stack chassis. Open
it alongside this recipe and mirror the field you're closest to.
The steps below add a field to an existing config struct (HttpConfig),
which is the common case — the struct's layer is already registered for
discovery, so a new field joins the --print-config dump for free. Adding a
brand-new config struct takes three extra steps, called out at the end.
Enable / disable flags
A capability that ships off (or on) by default exposes that switch as one
config-API bool, resolved through the same derive as every other knob —
not inferred from another field (a bound address, a configured path) and
not read out of env::var directly. Declare it with a false literal
default; a bool needs no parser:
#![allow(unused)] fn main() { #[cfg_attr(feature = "runtime", config(default = false))] pub enabled: bool, }
Name it for the intent: an opt-in cap that stays off until asked for calls
the field enabled, while an opt-out cap that runs until suppressed calls
it disabled. Both default to false, so the literal default reads as the
unsurprising state, and a chassis turns the behaviour on from one
documented AETHER_… key (or its CLI flag). At the composition site the
chassis maps the resolved flag to its structural choice —
cfg.enabled.then_some(cfg) for an opt-in cap — keeping the flag the
single source of the on/off decision. confique's native bool parsing
accepts 1 / true / yes / 0 / false / no, case-insensitive and
trimmed.
Steps
1. Declare the field with a #[config(...)] hint
Add the field to the struct in its cap crate and annotate it. The derive reads the hint to generate everything downstream:
#![allow(unused)] fn main() { #[cfg_attr(feature = "runtime", config(default = false))] pub require_https: bool, }
Most fields need no parser. A numeric, Duration, or bool field rides
confique's native env parsing: it trims the value, treats an empty one as unset
(falling back to the default), and hard-errors on a non-empty value that doesn't
parse — so a typo'd AETHER_… number stops the boot with the key named instead
of silently defaulting.
The hints you have:
default = <lit>— the literal default the layer resolves to when no config-file, env, or argv value is set.env = "..."/cli_long = "..."— pin the env key and--flagto an exact name when the field name doesn't match the historical wire shape. Absent these, the names come from the container'senv_prefix/cli_prefixjoined to the field name.csv_set— for aHashSet<String>field: the overlay accepts oneOption<String>, and the env side auto-wiresparse_csv_set(trim, split on commas, drop empties).nonzero— a resolved0coerces to the field default, for a knob where0is degenerate (a concurrency bound that would deadlock at zero). Requires adefault.ms_duration+layer_field = "..."— the domain field is aDurationwhile the layer carries<field>_ms: u32; the derive bridges viaDuration::from_millis.parse = <fn_path>— the escape hatch for a genuinely custom mapping, afn(&str) -> Result<T, impl Error>.fs'sparse_dir(an empty override is unset; the default is computed at runtime fromdirs::data_dir()) is the worked example. A plain numeric /bool/Duration/Stringfield never needs it.
The container attribute on the struct sets the prefixes both names derive from:
#![allow(unused)] fn main() { #[cfg_attr( feature = "runtime", config(env_prefix = "AETHER_HTTP", cli_prefix = "http") )] }
Write the field's first sentence as its --help summary
The derive lifts the first sentence of the field's rustdoc into that flag's
--help description and appends the env key and resolved default. So the
opening sentence is the copy an operator reads: a plain-language summary of
what the knob does, with units spelled out, no internal type names, no issue
or ADR references, and no leading AETHER_… env incantation. Everything a
maintainer wants — the adapter the flag swaps, the error it returns, the
wire-shape pins — goes below a blank doc line, where it stays in the source
and out of --help.
#![allow(unused)] fn main() { /// Reject plaintext HTTP URLs and allow only HTTPS. /// /// An `http://` URL is rejected with `HttpError::InvalidUrl`. #[cfg_attr(feature = "runtime", config(default = false))] pub require_https: bool, }
The first sentence there is the entire flag description a --help reader
gets; the detail paragraph names HttpError::InvalidUrl for whoever opens the
source, and never surfaces in the dump. A summary that opens with a type name
or an AETHER_… key renders that text verbatim, so the flag reads as
maintainer prose to the operator who runs --help.
2. Keep Default in sync — and let the test enforce it
A config struct declares impl Default separately from the derive's
default = ... literals (the derive feeds the layer; Default feeds direct
construction in tests and call sites). Add your field's default to both. A
*_defaults_match test is what keeps them honest — the HTTP server's
config_layer_defaults_match_the_named_consts, in
crates/aether-http/src/server/runtime/unit_tests.rs, is the shape to copy:
#![allow(unused)] fn main() { #[test] fn config_layer_defaults_match_the_named_consts() { use super::super::{DEFAULT_BIND_ADDR, HttpServerConfig, HttpServerConfigLayer}; use confique::Config as _; let layer = HttpServerConfigLayer::builder().load().expect("defaults load"); let default = HttpServerConfig::default(); assert_eq!(layer.bind_addr, DEFAULT_BIND_ADDR); assert_eq!(layer.bind_addr, default.bind_addr); // …one pair per field } }
<Name>ConfigLayer is the derive-emitted layer type — you don't write it, but you
do reference it from the test. Add an assertion for your new field. It loads with
no .env() source, so it's env-free and CI-safe (issue 464). Where the default
is also a named const, assert against the const on both sides so the literal,
the Default, and the const cannot drift apart in pairs.
Gotcha — the
runtimefeature gate. Every#[derive(...)]and#[config]attribute is wrapped in#[cfg_attr(feature = "runtime", ...)], including the struct-level derive. A capability crate also cross-compiles to wasm, where the config machinery isn't present, so the wasm build must carry only the plain struct. Clippy runs host-native and won't catch a missing gate — the wasm32 cross-build in CI is what fails on it. Anyparsehelper you add is#[cfg(feature = "runtime")]too.
3. Wire the argv overlay into the chassis CLI
The derive emits <Name>Overlay (here HttpOverlay) with an into_layer()
method. For a field on an existing struct whose overlay is already flattened into
a chassis CLI, the new field rides the existing overlay automatically — confirm
your struct's overlay is reached. HttpOverlay is imported into
crates/aether-chassis/src/cli.rs and flattened into CommonOverlay:
#![allow(unused)] fn main() { #[command(flatten)] pub http: HttpOverlay, }
CommonOverlay is in turn flattened into DesktopCli and HeadlessCli, so both
full-stack chassis expose the flag. ChassisCli::into_sources then assembles the
whole stack once — it loads the --config file into a ConfigSources and stages
every flattened overlay's argv layer onto it through the derived StageArgv impl
— and hands that stack to the chassis builder
(crates/aether-chassis/src/boot.rs):
#![allow(unused)] fn main() { let mut sources = cli.into_sources()?; // A value the chassis itself needs before composing. let namespace_roots = sources.resolve::<NamespaceRoots>()?; // Everything else: the builder resolves each cap's Config off the stack. builder.with_config_sources(sources).with_actor::<HttpCapability>(()) }
resolve reads the member's [section] out of the loaded file itself, so the
section name is declared once on the config type (#[config(section = "…")],
defaulting to its cli_prefix) rather than repeated at each chassis call site.
Reach for a chassis-side sources.resolve::<C>() only when the chassis needs the
value before composition (a driver's cadence, roots it must also pass
programmatically); an ordinary cap config is builder-resolved. For this config, a
file override is written under [http]:
[http]
require_https = true
load_chassis_config selects --config PATH first and falls back to
AETHER_CONFIG_FILE; either explicitly selected file is a hard boot error when it
cannot be read or parsed. Resolution extracts the named section with
file_section and preserves field precedence argv > env > config file > literal
default. A missing [http] section simply contributes no layer, while a present
non-table or malformed section is a hard ConfigError. Absent flags resolve
None and fall through, so adding argv support does not shadow env or file values.
The flag name is mechanical: take the env key, drop the AETHER_ prefix,
lowercase, hyphenate — AETHER_HTTP_REQUIRE_HTTPS becomes --http-require-https.
A bool flag accepts zero or one value (--http-disable ⇒ true,
--http-disable=false ⇒ false, absent ⇒ None).
4. Confirm the knob in the --print-config dump
Build and run any full-stack chassis with --print-config — it walks the same
declarations and prints every knob's env key, resolved value, source, default,
and doc, then exits before boot:
cargo run -p aether-chassis-headless --bin aether-headless -- --print-config
Your new field appears with its default. This command is the discovery surface:
the binaries exit before loading a selected TOML file, so use it to confirm that
the declaration is registered, not to test a [http] override. The dump is
ConfigManifest::dump, and the manifest is composition-derived (ADR-0156):
with_actor::<HttpCapability>(…) accumulates that cap's ConfigMember record —
its section plus its layer META — so a field on an existing struct shows up
with no extra wiring, and a chassis lists exactly the knobs it composes. If your
knob is missing from the dump, the field isn't reaching the layer (re-check the
#[config] hint and the runtime gate); if the whole struct is missing, its cap
isn't composed on that chassis.
5. Run the deterministic local tier
cargo fmt -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
Fix either failure locally, then push the implementation branch. CI owns the full docs/test/wasm/package matrix and catches feature combinations the native lint pass cannot.
Adding a brand-new config struct
If the knob doesn't belong on any existing struct, you're declaring a new
#[derive(aether_substrate::Config)] struct. Three steps beyond the above:
- Choose a stable TOML section. The derive takes it from the struct's
cli_prefix; pin a different one with#[config(section = "…")]when the historical file API and the flag prefix diverge. That string is the operator-facing file API, so it is chosen once and does not move. - Own it from a composed cap. Discovery is composition-derived: the derive
emits the
ConfigMemberimpl, andwith_actor::<YourCapability>(…)is what puts it in the chassis aggregate the--print-configdump and the unknown-key sweep walk. A config struct nothing composes appears nowhere. A non-cap chassis member is declared on the builder the same way. - Flatten its overlay into a chassis CLI. Import
YourOverlayintocrates/aether-chassis/src/cli.rsand#[command(flatten)]it intoCommonOverlay, or into a per-chassis root in its own chassis crate (crates/aether-chassis-{desktop,headless,hub}/src/cli.rs). The derivedStageArgvimpl on that root is what stages the overlay onto the source stack.
Verify against current code
This recipe names files, symbols, and methods that move. Before following it,
confirm HttpConfig, HttpConfigLayer, HttpOverlay,
load_chassis_config, file_section, ConfigSources::resolve, StageArgv,
ConfigMember, and ConfigManifest still exist where named — grep the
crates, and if a name has drifted, fix the recipe as part of your work.