Adding a chassis capability
Class: recompile. You're editing aether's Rust and rebuilding the
substrate — cargo plus the pre-flight loop.
A chassis capability is a native actor: an identity struct, a #[runtime] impl NativeActor block, and a line in a chassis builder that puts its
mailbox on the air. By the end you have a mailbox reachable by mail on
whichever chassis you wire it into.
This is the native half of the actor model. The authoring shape — init
/ wire / unwire, #[handler::<class>], addressing by type — is the same one
The actor model walks for components;
read that first if the #[actor] shape is new. The capability-specific
parts are the host machinery: where the code lives, the builder
registration that publishes the mailbox, and the in-process test path.
For the normative module shape every capability converges on — directory
layout, identity/runtime split, test placement — see
Capability module anatomy.
The exemplar
Trace crates/aether-audio/src/ while you read.
AudioCapability owns the aether.audio mailbox: a capability with a
small config, a synth on a thread of its own, and per-session state — the
bank assemblies and track loads in flight, each holding the reply it owes.
It answers both kind flavors this recipe teaches. aether.audio.note_on
is fire-and-forget; aether.audio.load_instrument is reply-bearing, and
answered later. The synth and its DSP are most of the crate and none of
this recipe: read the identity, the #[runtime] impl, and the load path,
which between them exercise every step below. Verify its names against the
current source as you go — a capability is a recompile-class recipe, so
the symbols here rot faster than the explainers (see
the staleness rule).
The identity lives in audio/lib.rs; the state and the handler
list in audio/runtime/mod.rs, with the handler bodies in
audio/runtime/handlers.rs and the load bookkeeping in
audio/runtime/load.rs; the owned kinds in
audio/kinds.rs.
1. Name the mailbox
A capability's mailbox name is its NAMESPACE const. Chassis-owned
mailboxes live under the aether.<name> prefix — aether.audio,
aether.render, aether.fs. Peers that declare depends(AudioCapability)
address the cap by type — ctx.send::<AudioCapability>(&kind) — which resolves to a
compile-time-const mailbox id derived from NAMESPACE, so there's no
host round-trip for addressing. Pick a name that isn't already claimed;
the builder rejects a collision at boot
(step 4).
2. Write the actor
A capability is split into two halves (ADR-0122). The identity is a
ZST struct carrying only the addressing; the state-bearing runtime
lives in a feature-gated runtime module. #[actor(singleton)] sits on
the identity in lib.rs, and a separate #[runtime] impl NativeActor for X in the runtime module names the runtime through type State
(ADR-0123):
#![allow(unused)] fn main() { // audio/lib.rs — the identity half, always-on. use aether_actor::actor; /// `aether.audio` cap identity: a ZST carrying only the addressing — /// `Addressable` (`NAMESPACE`, `Resolver`), the per-handler `HandlesKind` /// markers, and the singleton name-inventory entry, all emitted always-on /// by `#[actor]`. Its `aether.fs` reads are declared. #[actor(singleton, root, depends(FsCapability))] pub struct AudioCapability; // The runtime half — state, substrate-typed imports, and the `#[runtime] // impl NativeActor` — lives in `runtime/`, gated once here on the cap's // feature. The struct-hosted `#[actor]` above reads that module off disk // to lift the identity. #[cfg(feature = "runtime")] mod runtime; }
#![allow(unused)] fn main() { // audio/runtime/mod.rs — the runtime half, gated by the `mod runtime;` // line above. The substrate-typed imports enter only on a native build. use super::AudioCapability; use super::kinds::{LoadInstrument, LoadInstrumentResult, NoteOn}; use aether_actor::runtime; use aether_substrate::actor::native::{NativeActor, NativeCtx, NativeInitCtx, Pending}; use aether_substrate::chassis::error::BootError; /// The cap's mutable state — the synth's event queue and the loads in /// flight. `#[handler::<class>]`s receive it as `state: &mut Self::State`. pub struct AudioCapabilityState { /* … */ } #[runtime] impl NativeActor for AudioCapability { type State = AudioCapabilityState; type Config = AudioConfig; const NAMESPACE: &'static str = "aether.audio"; fn init(config: AudioConfig, _ctx: &mut NativeInitCtx<'_>) -> Result<AudioCapabilityState, BootError> { // … start the synth `config.output` asks for, or run without one … } // Fire-and-forget: the handler returns `()`. `note_on` pushes one // event onto the synth's queue. #[handler::tell] fn on_note_on(state: &mut Self::State, ctx: &mut NativeCtx<'_>, mail: NoteOn) { state.handle_note_on(ctx, mail); } // Reply-bearing, answered later: hold the owed `LoadInstrumentResult` // and forward the `.sfz` read with the held reply as its context. // See "audio's held-reply variant" below. #[handler::request] fn on_load_instrument( state: &mut Self::State, ctx: &mut NativeCtx<'_>, mail: LoadInstrument, ) -> Pending<LoadInstrumentResult> { state.handle_load_instrument(ctx, mail) } } }
The pieces:
- The identity ZST —
pub struct AudioCapability;carries no state.#[actor(singleton)]emits its always-onAddressable+HandlesKind<K>markers, so a wasm guest writingctx.send::<AudioCapability>(&kind)underdepends(AudioCapability)compile-checks even on a build where the runtime half is gated out. #[actor(singleton)]declares the cardinality —singletonfor a chassis cap;instancedis the counterpart for per-instance actors (the engine proxy uses it). It reads the sibling runtime module off disk to lift the identity from the#[runtime] impl. It carries that module's ownuseitems along with the markers, which is why the identity file above imports nothing butactor— spell the kind imports in the runtime module absolutely (crate::kinds::…, notsuper::kinds::…) and the identity never restates them. Asuper-rooted, glob, or#[cfg]-gateduseis left behind (it would resolve differently one module down), so a kind reached that way still needs an import in the identity file.#[runtime] impl NativeActor for AudioCapabilitycarries the behaviour. The#[runtime]attribute emits the runtime surface ungated — the#[cfg(feature = "runtime")]rides themod runtime;line inlib.rs, so every impl already exists only on a build where the runtime module is present. No inner#[cfg]is needed.type Statenames the runtime struct holding the cap's mutable state. It lives in the feature-gatedruntimemodule so it never compiles into a wasm build, and#[handler::<class>]s receive it asstate: &mut Self::State.type Configis the struct of the cap's knobs —AudioConfighere — or()for a cap with none (step 3). The chassis resolves it and hands it intoinit.init(config, ctx)builds the runtime state (it returnsSelf::State, notSelf). The mailbox is already claimed;ctxis aNativeInitCtxexposingself_wake::<K>()andactor_probe()for a thread the cap spawns,publish_handlefor a driver-facing handle bundle, andguest_ctxfor a wasm guest host — audio uses none of them: it starts its synth thread and keeps the handle.initruns before the dispatcher starts and before any peer's dispatcher runs — no mail yet. ReturnErr(BootError::…)to abort the chassis build.wire(&mut self, ctx) -> Result<(), BootError>(optional, defaultOk(())) is the post-init mail-allowed hook: peers are addressable here, so subscribe to input streams or announce yourself fromwire, notinit. ReturnErr(BootError::…)to abort the chassis build; the cap'sunwirestill runs, with every cap that wired before it.unwire(&mut self, ctx)(optional) is the symmetric pre-shutdown hook. Audio needs neither.#[handler::<class>] fn on_x(state: &mut Self::State, ctx, mail: K)infers the kind from its third parameter. The first parameter is the runtime state, threaded explicitly because the identity carries none — take&Self::Statefor a read-only handler,&mut Self::Stateto mutate; the dispatcher owns the cap on one thread, so state is plain fields, no locks. The handler receivesmailby value.
Reply-bearing handlers, and audio's held-reply variant
A self-contained reply-bearing handler returns its reply kind (-> R,
ADR-0112): the handler computes the answer this turn and the dispatcher
sends it back. A fire-and-forget handler returns ().
Audio's load_instrument answers later, because it can't answer this
turn — it must round-trip aether.fs first, once for the .sfz file and
once for each sample it names. It is still a #[handler::request],
declared -> Pending<LoadInstrumentResult> (ADR-0243):
ctx.hold::<LoadInstrumentResult>() returns the
Pending<LoadInstrumentResult> receipt the handler returns, which sets its
row, and a Held<LoadInstrumentResult> ticket that answers the one
LoadInstrumentResult from a later turn. hold requires the reply kind to
implement HeldReply, whose hand-written unanswered() is the failure the
engine sends in its place if the capability closes first while the engine
keeps running (an engine teardown sends nothing):
-
on_load_instrumentholds the reply. With no synth to load into it answersErrat once. Otherwise it forwards anaether.fs.readfor the.sfzwithctx.send_with_context::<FsCapability>(&read, AudioLoadContext::Instrument { held }), which compiles becauseAudioCapabilitydeclaresdepends(FsCapability). The held reply rides the read's request context, so each request has its own read and its own reply: nothing is shared between two loads, even of one file. -
on_read_resulttakes the context back withctx.take_context::<AudioLoadContext>(), and the context says which read this answers. For the.sfzit parses the file, moves the held reply into aBankAssemblykept in state under a mintedassembly_id, and sends one read per sample, each withAudioLoadContext::Sample { assembly_id, slot }. A read or parse that fails answers the held reply withErrthere and then. -
When the last sample arrives it stages the decode and assembly off the actor's turn (ADR-0243 §9). The task owes no reply of its own: the assembly stays in state with its held reply, and the task carries only a kind naming it:
#![allow(unused)] fn main() { ctx.stage_blocking_with::<BankAssemblyOutput, BankAssemblyKey>(BankAssemblyKey { assembly_id }) .start(ctx, move || assemble_bank(name, ®ions, &sample_bytes, target_rate)); } -
on_instrument_assembled(the#[handler(task)]completion) receivesTaskDone<BankAssemblyOutput>, takes itsBankAssemblyKeywithctx.take_context(), removes the assembly, registers the bank under a session-scoped instrument id, and answers the request withassembly.held.answer(ctx, …).
Trace the full correlation in runtime/handlers.rs and runtime/load.rs
rather than reading it re-explained here.
The kinds a handler receives must exist in the substrate kind inventory
so the dispatcher can decode the wire bytes. Audio owns its kinds:
NoteOn, LoadInstrument, LoadInstrumentResult, and the rest live in
audio/kinds.rs beside the cap that dispatches them (ADR-0121),
always-on and wasm-safe (they need only aether-data + serde). Their
inventory::submit! descriptor entries ride the Kind derive, so
aether_kinds::descriptors::all() surfaces them. Registering a kind whose
decode the dispatcher must find is the separate Adding a substrate kind
recipe.
The reply gotcha
ctx.reply(&result) / ctx.reply_to(source, &result) — the
NativeBinding handler-reply path — is the complete router: it reaches
every SourceAddr, including the Component local-RPC-server reply target
an MCP-spawned engine tags. A reply that must outlive the handler (answered
later from an embedder loop, say) retains the request's InboundMail guard
and replies through it, which takes the same route. Reply through one of
these and nothing else: a hub-only reply path once answered just Session
and EngineMailbox senders and silently dropped the Component reply, so
an MCP-spawned caller's reply never landed (iamacoffeepot/aether#1321).
3. Give it a config if it needs one
A cap with tunables declares a struct and derives Config on it, so its
knobs flow through the same config-file/env/argv source stack every other
cap uses rather than a raw env::var read. Audio's is AudioConfig: where
the synth's samples go, and the sample rate to ask for. A cap with no knobs
uses type Config = ();. That dance —
#[derive(aether_substrate::Config)], the emitted overlay, the struct's
TOML section, and flattening the overlay into the chassis CLI — is
Configuration. You do not hand the resolved
struct to the builder: composing the cap declares its config type, and the
chassis resolves the value off the source stack. Keep an empty config a struct
rather than () if you expect knobs later, so the composition site doesn't
churn when the first one lands.
4. Register with the chassis builder
A mailbox is only on the air once a chassis builder claims it. The
builder is aether_substrate::chassis::builder::Builder; you add a cap
with with_actor::<X>(params) (ADR-0070 / ADR-0071),
where params is the cap's composer-supplied construction input (A::Params),
not its config:
#![allow(unused)] fn main() { builder.with_actor::<AudioCapability>(()) }
Composing a cap also accumulates its A::Config member into the chassis config
aggregate (ADR-0156), which is what puts its knobs in --print-config and the
unknown-key sweep. When a composer needs to pin an explicit config value rather
than let the stack resolve it, with_actor_configured::<X>(params, config) is
the paired form — the A::Config type binds the value to the actor at the call,
so an orphaned override can't be written. Audio's handler tests boot it that
way, to run the synth with no device:
#![allow(unused)] fn main() { builder.with_actor_configured::<AudioCapability>((), AudioConfig { output: AudioOutput::Null, requested_sample_rate: None }) }
Where that line goes depends on which chassis should carry the cap:
- Desktop and headless together — add it to
with_common_capsincrates/aether-chassis/src/boot.rs, the shared composition those two chassis call.FsCapabilitylives here. Put a cap here only when both chassis serve it and everything it depends on;AudioCapabilityneeds an audio device, which headless does not serve, so it is not here. Adding it to the.with_actor::<_>()chain is all it takes: the--describemanifest is claim-derived (ADR-0155), so a cap appears in the roster the moment it claims a mailbox — there is no parallel namespace list to keep in lockstep. - The substrate-harness chassis — the in-process harness does not call
with_common_caps; it has a separate, reduced builder chain incrates/aether-harness-substrate/src/chassis.rs; add the capability there too when scenarios should drive it, and thread any required config throughSubstrateHarnessEnv. - One chassis only — add it to that chassis's own builder chain:
desktop/chassis.rs,headless/chassis.rs, orhub/chassis.rsin the chassis crates.AudioCapabilityis composed in the desktop chain alone. The desktopRenderCapabilityis booted as a pumped actor by the desktop driver (ctx.boot_pumped_actor::<RenderCapability>(…)) because it must run on the winit thread.
A chassis that cannot serve a capability composes nothing for it, not a stub claiming its mailbox (ADR-0232 §6), so a component that depends on it is refused at load there.
A pumped actor is reserved at the Claim stage, before any passive's
init: on a driver chassis the driver's claim hook calls
ctx.claim_driver_mailbox(…), and on a passive chassis the composition calls
reserve_pumped::<A>() and terminates in build_passive_with_start(|passive| …),
whose start boots the actor with passive.boot_pumped_actor::<A>(…). The
reservation is a live route, so a passive may declare depends(A) on a pumped
actor, and mail sent to
the slot waits in its inbox until the pump boots. A reservation that is never
booted fails the build, naming the slot: a plain build_passive() boots
nothing, so a reservation fails it too.
The builder claims A::NAMESPACE as it boots each cap and enforces
one claimant per name: a second cap claiming an already-owned mailbox
fails the build with BootError::MailboxAlreadyClaimed { name } (or a
namespace-ownership error for a NAMESPACE collision across types). This
is the guarantee that a well-known name has at most one claimant in any
composition, so no cap silently shadows another.
Boot is multi-pass across every cap: claim → init → wire → spawn,
synchronized so that at init time every peer mailbox is claimed and at
wire time every peer has an instance. Declaration order is boot order.
5. Passive cap or driver?
Most capabilities are passive: they sit on a dispatcher and answer
mail, added with with_actor. AudioCapability is passive. An executable chassis
also composes exactly one driver — the cap that owns the chassis main thread
and its lifetime (the winit loop on desktop and the std timer on headless). The
shared SignalDriverCapability from aether-chassis, which the hub and the
Bloomery compose, instead owns the SubstrateBoot and blocks that thread on
SIGINT/SIGTERM; RpcServerCapability, a passive actor, owns the socket
listener. A driver implements DriverCapability (not NativeActor) and is
supplied with .driver(d) rather than .with_actor; the type-state builder
enforces exactly one. The in-process SubstrateHarness uses build_passive_with_start and lets its
embedder drive it, so it deliberately has no driver capability.
If the cap drives — owns a loop or a peripheral — its name carries
Driver: DesktopDriverCapability, HeadlessTimerDriverCapability. A
plain FooCapability reads as a passive sink. Don't name a passive cap
*DriverCapability. Most new caps are passive; you reach for a driver
only when standing up a new chassis kind.
Heavy native deps
Audio's runtime half pulls cpal, a native-only dependency, through its
generic runtime feature on the mod runtime; line:
#[cfg(feature = "runtime")] mod runtime;. The identity markers stay
always-on (so guests still address the cap by type) while cpal only
enters when the feature is on. The renderer's wgpu and fontdue gate
the same way. A cap whose runtime
needs no heavy dep still gates its mod runtime; line on the generic
runtime feature, so the split holds without minting a cap-specific gate.
6. Test it in-process
A native cap compiles into the substrate, so its tests drive a real
handler with mail — no wasm, no FFI, no MCP session. (export!'s FFI
shims are wasm32-only and belong to components, not capabilities; a
native cap has nothing to cross-compile.) The in-crate pattern is the
pumped rig, which audio's own unit tests do not use; read it in
crates/aether-fs/src/runtime/mod.rs, whose PumpedFs
fixture boots FsCapability this way:
- Boot the cap pumped on a real chassis, driven the way a pumped chassis
driver drives its slot (ADR-0161 §Decision 2):
fresh_substrate()gives the(Arc<Registry>, Arc<Mailer>)seed,boot_bare_test_chassis(®istry, &mailer)builds the passive chassis over it, andPumpedDriver::boot(chassis, config, params)boots the cap on that chassis and drains once. A cap that depends on another registers a stand-in for it first, withtesting::registered_ref, whose closure forwards each dispatch it receives onto a channel the test reads. - Send mail through the pumped slot, never call a handler directly:
cap.send_and_settle(cap.chassis().actor_ref::<FsCapability>(), &Read { … }, None)tracks the send as a chassis root and pumps the slot until that root settles, so the mail runs through the cap's#[actor]-generated dispatch exactly as production would — including a-> Pending<R>handler'sUncheckedarm, which accepts the returned receipt itself. The test never hand-disarms aPending. - Assert what the handler sent by reading the stand-in's channel (the
registered_refclosure forwards each dispatch it receives), and assert what it replied with by sending as a session-origin mail (Some(ReplyTarget::Session { session, correlation })) and decoding the settled root'sEgressEvent::ToSessionoff the egress receiverfresh_substrate_and_rxreturns beside the seed. For a look at state no sent or replied mail exposes,cap.host_turn(|state, ctx| { … })runs a closure directly againstStatebetween sends.
A test that needs a proven peer (a subscriber, a sender, a shard) registers
it with testing::registered_ref, which returns the peer's reference, and
testing::registered_binding returns the binding beside its own reference.
registered_ref takes a root name or a /-rendered lineage path, so a test
can stand a route at a nested position (a collision a spawn will claim), and
testing::drop_ref retires it.
A test that needs a local component as the inbound's sender takes the
Source, and the chain root when it needs one, from a mail its
registered_binding binding actually sent, and writes a root that is only a
token as testing::token_root(n), so it names no mailbox. The in-flight
lineage a NativeCtx constructor takes is optional: None for a context with
no inbound chain, Some(root) for one running inside a chain.
Three tests in aether-audio's tests/handlers.rs anchor the held-reply
flow, over a SubstrateHarness composing the real cap beside aether.fs.
load_instrument_assembles_the_bank_and_assigns_increasing_ids proves the
read, the sample fan-out and the assembly end in one answer per request.
concurrent_bank_loads_of_one_sample_answer_their_own_callers proves two
loads in flight at once, naming the same sample, each answer their own
caller, because each read is told apart by its request context and not by
its path. load_instrument_replies_err_for_each_failed_step proves every
failure arm answers the held reply.
For an end-to-end check across the real in-process boundaries — rendering, the
frame loop, and the capabilities explicitly installed by its reduced builder —
drive SubstrateHarness instead. It boots that
chassis from a Rust thread and sends mail through the same encode path the MCP
tool uses. Supply namespace roots when the scenario needs aether.fs.
7. Smoke it over MCP
If the cap fronts a load-bearing path, exercise it once live: bring up the
MCP harness, spawn_substrate, send_mail one of
its kinds at the cap's mailbox name (send_mail a note_on or a
set_master_gain at aether.audio), and read actor_logs for
aether.audio. Unit tests and
clippy don't exercise the spawned-engine reply route (the
SourceAddr::Component reply gotcha lives there), so a live smoke catches
what the in-process test can't.
Staleness
This recipe carries file paths and symbol names, so confirm them against
the current source before following it. The exemplar is
crates/aether-audio/src/ — if a name here doesn't
match what's in the tree, fix the recipe as part of your change. The
pointer is to the real cap, not a frozen copy, exactly so it tracks the
code.