Components & lifecycle

Governing ADRs: ADR-0074 (one actor model, two hosts), ADR-0024 (the dual-target FFI convention), ADR-0028 / ADR-0033 (the kind + handler manifests in the wasm), ADR-0090 (boot config), ADR-0038 / ADR-0101 (binding-stable hot-swap; the earlier drain design in ADR-0022 is superseded), ADR-0096 (several actor types per module, selected at load), and ADR-0114 / ADR-0099 (inline children and their lineage-correct addressing). The authoring and loading surface here is stable — it's what the reference component aether-kit (a multi-actor module: camera, camera-controller, and mesh viewer) is built on, and the signatures were read from the current SDK.

A component is the wasm host of an actor. The shared model — the #[actor] block, the init / wire / unwire lifecycle, handlers, addressing by type — is the actor model, and a component is one of its two hosts: an actor that arrives as wasm bytes, runs sandboxed behind the wasm wall, is reached by the substrate through an FFI trampoline, and can be loaded, dropped, and hot-swapped at runtime. The other host, a native capability, is the same shape against NativeActor.

This page is the wasm-specific machinery — read the actor model page first for how you actually write one; everything below is what's different because the actor lives across an FFI boundary and is loaded into a running engine.

export! and the FFI boundary

Authoring a component is authoring an actor (#[actor(root)] impl WasmActor for C), plus one line that doesn't exist on the native side:

#![allow(unused)]
fn main() {
aether_actor::export!(public = [Hello]);
}

Every entry is keyed — public = [..], boot = B, private = [..], generators = [..] — in any order, each key once; a bare or mixed call is a compile error that shows the keyed spelling. This line is required — without it the wasm has no FFI exports and the substrate can't drive the actor. It emits the #[no_mangle] entry points the host calls across the boundary (init, wire, receive_p32, unwire) and two wasm custom sections:

  • aether.kinds.inputs — the manifest of every #[handler::<class>] kind (plus its doc, and whether a #[fallback] is present), generated by #[actor]. This is exactly what describe_component reads back to tell a live engine what the component accepts — your doc comments, filtered through a # Agent section if you write one, ride along.
  • aether.namespace — present only when the module exports exactly one actor; it carries that actor's load namespace.

You never write extern "C" by hand. The _p32 suffix on the pointer-taking exports (receive_p32, on_rehydrate_p32) is the dual-target FFI convention (ADR-0024): wasm32 addresses are 32-bit, so those shims take u32 pointers and lengths. The exports are wasm32-only — a native (host) build of the same crate carries no FFI symbols, which is why a host-side test of a component drives it through the in-process transport rather than the FFI path.

Several actors in one module

A crate can export more than one actor type (ADR-0096):

#![allow(unused)]
fn main() {
aether_actor::export!(public = [RootManager, Panel, Toolbar]);
}

The module then carries every listed type's code behind one FFI surface, and the load path picks which type an instance becomes (below); a boot type is not listed again under public. A load or boot entry that names no export needs a module with exactly one selectable (non-boot) export; this module exports three, so a load must name one (ADR-0241 §9, superseding the multi-actor selection rule ADR-0138 first stated). A module that exports exactly one actor, export!(public = [Hello]), still loads without a selector. The aether.kinds.inputs manifest grows to one handler group per exported type, each tagged with its namespace, so the loader and describe_component read each type's surface separately. Grouping actors that belong together — a subsystem's coordinator and the panels it manages, say — into one module is the intended use: it ships and versions them as a unit, and lets a running instance spawn its module's actors as inline children (below).

The generators = [aether_bloomery_program::bundle] key names the one bloomery export generator. export! stays the only author entry; bundle is a function-like macro hook, not a second export macro and not a runtime trait. #[actor], #[program], and #[reactor] emit a same-name companion macro so use, pub use, and as aliases carry descriptor metadata. Generators receive actors (each type's namespace plus optional namespaced extensions) and exports (the types the module will bind). Ordinary actors have no extra extension; #[program] adds aether_bloomery_program and #[reactor] adds aether_bloomery_reactor. bundle keeps ordinary actors in exports and replaces every program and reactor with one root exported as aether.bloomery.bundle (BUNDLE_NAMESPACE), at the first one's position. Every bundle is content-addressed (see Publishing a module), so the root publishes as aether.bloomery.bundle.<module hash> while export: Some("aether.bloomery.bundle") still selects it. The root is instanced, and the bundle driver loads it keyed by its unit, so it is born at aether.bloomery.bundle.<module hash>:<unit key> (ADR-0240 D4). A module provides programs, reactors, or both; neither is a compile error. With programs, the root answers Invoke with Invoked through a per-seq inline child and writes aether.bloomery.programs. The program root relays each invocation's fetch-on-miss (ReadArtifact) and program API call (ApiCall) to the Invoke's sender, the driver, and relays the answer back (ADR-0240 D6). With reactors, it answers Warm, Event, and StatusQuery to the caller (Warmed / Evaluated / Status) and writes aether.bloomery.reactors. Each role keeps its own state. Program NAMEs are unique and Mode::Pure; reactor NAMESPACEs are unique string literals, checked with #[rule] idents as ReactorName / RuleName. Envelopes stay on their original types in actors; their other extensions are not copied onto the root. type Alias = T is not followed. The pipeline ends in one keyed no-generator export!. The root takes no config and is not a boot actor, and boot may not name a program or reactor. The root is one of the module's public exports, so export: Some("aether.bloomery.bundle") selects it.

#![allow(unused)]
fn main() {
aether_actor::export!(
    public = [Probe, ProbeWithConfig, Summarize, SourcePublisher, SourceWitness],
    generators = [aether_bloomery_program::bundle],
);
}

Publishing, spawning, loading, and dropping

A component enters and leaves a running engine by mail to the aether.component mailbox:

kinddoesreply
aether.component.publishbind a module's namespaces (admission), register its kinds; a successor republishes every live instance of them as one groupPublishResult
aether.component.spawnstand up an instance of a published type at NS, NS:key, or parent/NS:key, or return a live oneSpawnResult
aether.component.loadpublish the module, then spawn the export it selects, in one callLoadResult
aether.component.dropclose the instance, which runs the guest's unwire; answers once the guest is released; its name tombstonesDropResult
aether.component.unpublishwithdraw one published namespace; refused while any instance of it is live, naming itUnpublishResult
aether.component.listenumerate the engine's live componentsListComponentsResult
aether.component.describeintrospect one component's receive-side capabilitiesDescribeComponentResult

A bare Publish { code, configs } binds every namespace the module exports; identical bytes already bound are a no-op, and a first publish spawns the module's boot once (ADR-0147). A Spawn { namespace, key, parent, config } then stands up an instance of a published type: a live name answers SpawnResult::Live without re-init, an absent name answers Spawned, and a tombstoned name (§8, below) is refused. A namespace naming a native type — one linked into the binary rather than published from wasm — is refused: native types are composed by their chassis or parent, and spawning one by mail is not supported yet. aether.component.load combines the two in one call: it publishes wasm, then spawns the export it selects, keyed by name.

LoadResult::Ok carries the component's canonical path (so a caller that omitted name learns the key the load took) and the parsed ComponentCapabilities (handlers, fallback, doc, config) read from the manifest. It carries no mailbox id. A loaded component is named as a native actor is (ADR-0241 §5): a singleton registers at its published namespace NS, an instanced type at NS:KEY, and one loaded beneath a parent at parent/NS:KEY — that full string is the address you send subsequent mail to. A name nothing is registered at warn-drops; always use the path from LoadResult.

The successful reply is sent by the loaded component itself: the component host waits with the load's held reply until the guest's birth completes, then hands it off to the guest (as aether.component.load_delivered, ADR-0243 §9), and the guest answers the requester in its own name. An actor that loaded a component, native or guest, therefore takes ctx.sender() from the reply as its proven reference, and casts it once, there, to the protocol it will send the component, since an erased reference has no send verb (ADR-0231 §4); an embedder reads the stamped sender off the reply event and casts it the same way (ADR-0230). LoadResult::Err comes from the host, since no actor was loaded.

aether.component.drop names its component by target, its actor path; the host parses and proves it once at receipt, and an address with no live component answers Err naming it. aether.component.publish names no instance: the module's namespaces are the group it binds, or, for a successor, republishes.

The engine compiles each distinct module, and parses its custom sections, once per content hash (the BLAKE3 hash of its wasm bytes). Every load, module boot, and publish of the same bytes shares that one entry, which lives while its publication or any of them holds it. A publish checks each asset into the engine blob store as its own deduplicated blob held by the Module; the file bytes are let go once the module is built, while each asset lives exactly as long as a Module holds it (ADR-0241 §2). A guest reads its assets from its own module in every hook — init, wire, all handlers, on_rehydrate, and unwire — through the Assets ctx trait (ADR-0250). The surface has two verbs. asset_blob(name) hands the asset over as a Blob the guest holds by handle: the asset's own store entry, so no payload byte enters guest memory, and sending it on as a Blob field gives an in-process recipient the same bytes uncopied. It is the verb for a bundle that is mostly payload and routes an asset to the actor that makes it resident. asset(name) copies the asset into guest memory as a Vec<u8>, for an actor that parses or transforms the payload itself, or whose asset is small. A name the catalog does not carry answers None, as do both verbs. A Spawn of a published type always builds an instance that can read its assets, whoever asks for it: a boot manifest entry spawns this way, every instance it stands up, each replica of a replicas: N entry included, reading its assets from its own module in wire and in every later hook.

For a multi-actor module, the load also chooses which exported type to instantiate: aether.component.load takes an optional export selector — the target type's NAMESPACE — and stands up that type. Omitting it works only for a module with exactly one selectable (non-boot) export; a module exporting several is refused with an Err that names the exports. The LoadResult reports the selected type's capabilities. A selector naming a type the module doesn't export is a clean load error. A single-actor module is unambiguous, so an omitted selector is the whole story there.

The selected type's cardinality decides the load's key (ADR-0241 §5). A singleton is named by its namespace, so a load names no key for it: loading the singleton RootManager export with export: "ui.root" and no name registers it at ui.root, and a load that names any key for a singleton, its own namespace included, answers Err ("ui.root is a singleton; a load names no key") before the module publishes or anything is staged. An #[actor(instanced)] type is named by the load's name, or, when the load names none, by a counter the spawn allocates, as a native spawn_child with Subname::Counter is. Several instances of one type are therefore several loads of an instanced type; a replicated boot entry names every replica, so it must select one.

A load places the selected type at the component host, and a module boot type is always placed there, so each must declare root — #[actor(root)], or #[actor(instanced, root)] for an instanced type (ADR-0241 §5; see Declaring placement). The host reads the permission from the module's aether.actor.lineage section: a load whose selected type or boot type has no Root record, and a republish whose successor module's boot type has none, answer Err naming the type and the placements it does declare, before the module publishes or anything is staged. A type whose only placement is a parent — child_of(..) — is reached through that parent, never loaded at the host.

A drop closes the instance's trampoline entirely — nothing resident is left at that lineage — and its name tombstones for the engine's lifetime (ADR-0241 §8). Its route reads Dropped, so mail to it drops, every watcher receives a MonitorNotice, and a later load or spawn of the same name is refused as retired. A second drop at the path is refused. Load or spawn under a new key to bring the component back.

The close is what releases the guest: it runs the guest's unwire, answers each reply the guest still holds with its registered unanswered value, and drops the instance. The guest's unwire runs on its inline children first, children before their parents and deepest first, and on the entry actor last. The drop request is answered DropResult::Ok once that is done, so a caller that reads Ok reads a released guest. Mail the guest sends from unwire starts its own chains, outside the drop's, and is on its recipients' inboxes before the drop answers. The same close runs when nobody drops the instance and the engine tears down (ADR-0247 rule 5): a guest's unwire runs on every exit of its instance, so what its wire created can be released there.

A guest whose wire fails fails its birth (rule 3). A wire that returns Err answers the load or spawn Err with the guest's message; the guest then runs its unwire and is dropped. A wire that traps answers Err too, and the guest is released without running any more of its code. Either way no instance is left at the name, nothing the guest sent from wire leaves, and the name is free for another load. A boot manifest holding such a component fails the chassis build.

In practice you drive this through the MCP harness — publish(engine_id, selector, configs?), spawn(engine_id, namespace, key?, parent?, config?), load_component(engine_id, selector, namespace?, key?, config?, config_path?) (publish then spawn), terminate_substrate(...) — where selector resolves against the hub's content-addressed component registry (ADR-0116). Stage the wasm first with upload_component, which takes the path and reads the bytes for you (tool JSON never carries the wasm buffer; the wire kind does) and returns {hash, name} to load by. The component's kind vocabulary travels inside the wasm's aether.kinds custom section (ADR-0028), so the loader declares nothing — the substrate reads the types directly off the binary.

Publishing a module

The registry owner keeps a publication table: which code implements each namespace the engine publishes (ADR-0241 §3). Every native actor namespace linked into the binary is published when the registry is built. Every load and every publish binds its module before anything spawns or is swapped, through one owner batch that runs admission (§4) and then registers the module's kinds, all or nothing. Admission reads manifests only and refuses the whole module at the first failing namespace:

  • Namespace. No exported namespace may be native. A module that holds one of the candidate's exported namespaces is its predecessor, and the candidate must export every namespace each predecessor exports: a namespace, once published, stays published until aether.component.unpublish withdraws it.
  • Contract growth. Each exported namespace keeps its predecessor's rows and #[fallback]; rows may only be added. Each private child type (export!(private = [..])) a predecessor declares must still be declared, privately or as an export, with rows that only grow, so an inline child's alias never advertises a row its code no longer handles.
  • Same hash. Publishing bytes whose hash already holds every one of its namespaces changes nothing; its kinds register again, which is idempotent.

Content-addressed modules. A module carrying the aether.content_addressed custom section (CONTENT_ADDRESSED_SECTION, one version byte whose presence is the whole signal) publishes each namespace it exports as NS.<hash>, its BLAKE3 module hash in 64 lowercase hex. Every build is then its own publication, so no build is another's predecessor and the rules above never compare two builds; loading the same bytes again is the same-hash no-op. Inside the module each type keeps its declared NS, which the export selector names. A content-addressed module may export no namespace longer than 191 bytes, so NS.<hash> stays one 256-byte segment; a longer one fails check-in, naming it. The bundle generator marks every bundle content-addressed, so a bundle root publishes as aether.bloomery.bundle.<module hash>, and each built bundle adds one publication.

A refusal answers LoadResult::Err or PublishResult::Err with module publish refused: <namespace> … (<rule>), and nothing is spawned or swapped. A publish of a successor publishes too, so its new kinds register. A load that publishes and is then refused at spawn (an unmet dependency, a failed module boot) leaves its module published: publish and spawn are separate steps. A published module stays resident until its namespaces are unpublished.

Operators unload a bundle by dropping its instances and unpublishing it: send aether.component.drop for each live instance, then aether.component.unpublish for its namespace. The unpublish is refused while any instance of the namespace is live, naming it. Withdrawing a publication does not resurrect tombstoned instance names: a later spawn at the same instance name is still refused as retired. Publishing the same bytes afterwards binds as a first publish, and the module's boot is not spawned again.

Boot configuration across the boundary

A component takes typed boot config the same way a capability does — declare a type Config and the chassis threads it into init (ADR-0090). What's wasm-specific is the carrier: the config rides as raw bytes on LoadComponent.config, encoded at the hub / MCP edge (SDK-typed, not wire-typed), so the substrate stays byte-transparent and never needs the config's Rust type. The MCP tool accepts the source value as inline structured config JSON or a config_path to JSON and performs that schema encoding; callers do not supply raw wire bytes through tool JSON. The two JSON sources are mutually exclusive. Omit type Config and the macro synthesizes () and injects the unused argument, so a no-config init stays terse. A declared config kind shows up in the component's advertised capabilities, so describe_kinds can resolve its schema.

A config may name a peer by path: an ActorPath<R> when the component needs one actor type, or a ProtocolPath<P> when it needs only a protocol. Its decode in init proves a ProtocolPath<P> against the routes published at that moment, so the peer is loaded first; a path that does not prove refuses the load. Naming a peer by protocol has the rules and the cost.

Spawning children inline

A running instance can stand up another actor from its own module without a fresh load, as an inline child (ADR-0114). The child shares the parent's wasm instance, slot, and run-token, yet is addressed and mailed like any actor. Where a native capability spawns any permitted Instanced actor with ctx.spawn_child, a component spawns one of the Instanced types its export! lists. The spawner declares the child in spawns(..), and the child lists every parent it may sit beneath in one child_of(..) list:

#![allow(unused)]
fn main() {
#[actor(root, spawns(Panel))]
impl WasmActor for RootManager {
    #[handler::tell]
    fn on_open_panel(&mut self, ctx: &mut WasmCtx<'_>, _: OpenPanel) {
        // -> Result<InlineChild<Panel>, SpawnError>: the child's `init` has run by the time this returns
        let _ = ctx.spawn_inline::<Panel>(Subname::Counter, &PanelConfig { /* … */ });
    }
}

#[actor(instanced, child_of(RootManager))]
impl WasmActor for Panel { /* … */ }
}

A ctx that omits its actor is typed by it: the macro reads WasmCtx<'_> as WasmCtx<'_, Self>, so the ctx reaches only the actors the component declares with depends(R). The ctx's type arguments are receiver, sender, mode: the actor first, the sender the handler requires second (Anyone when it states none), and the reply mode third (WasmCtx<'_, Self, Anyone, Unchecked>); spell WasmCtx<'_, Erased> for the untyped view.

The one bound RootManager: Spawns<Panel> carries both proofs the spawn needs. spawns(..) is the rebuild manifest: every export! that lists the spawner must list each declared child. The emitted Spawns<Panel> impl also names RootManager's position in Panel's child_of(..) list as its Placement, so a spawns(Panel) whose Panel does not list the spawner fails to compile at the declaration. The ctx reads its parent from its registry actor tag and returns SpawnError::ParentIdentityUnavailable when it has none. A child type whose declared dependency has no Live route is refused before its alias is allocated, as SpawnError::DependencyNotLive. The child's init runs in-process during the call, so an init failure comes back as SpawnError::InitFailed, and the returned InlineChild<Panel> checks later sends against Panel's handlers.

An erased or wire ctx does not know its actor, so it spells the spawn spawn_inline_child::<RootManager, Panel>(...), naming the parent, and the SDK checks that name against the ctx's registry actor tag before it allocates the alias. The dynamic spawn_inline_child_by_tag form remains available for data-driven assembly, but its generated export resolver rejects unknown exports, non-instanced actors, and actors whose child_of(..) does not name the actual runtime parent before allocating an alias.

A spawner declares the children it spawns through the typed inline verbs in #[actor(spawns(..))], and every export! that lists the spawner must list each declared child, because that list is the set a hot reload rebuilds. A child the host should never load by selector goes under private = [..]:

#![allow(unused)]
fn main() {
#[actor(root, spawns(Panel))]
impl WasmActor for RootManager { /* … spawns Panel inline … */ }

aether_actor::export!(public = [RootManager, Sibling], private = [Panel]);
}

Both halves are compile errors. A typed inline spawn of a child the spawner does not declare fails at the spawn site with "RootManager spawns Panel inline but does not declare it", and the note names spawns(..). A declared child the export! lists neither under public nor under private fails at the export! with "Panel is spawned inline by an actor this export! lists, but this export! does not list it", and the note names private = [..]. The check holds for another crate's actors too: a re-exported actor is an ordinary public entry, and the export! must list every child it declares.

Subname::Counter has the host assign a bare monotonic counter — 0, 1, … — for when you'll track it by the returned handle; Subname::Named("inventory") gives it a stable discriminator you can render and address. A spawned actor nests under the spawner's registered lineage under its own type's namespace (ADR-0099, ADR-0241 §6): if a loaded root is example.root and it spawns an example.inventory child named main, the child is example.root/example.inventory:main, and another generation adds another /<child NS>:<subname> node. Its MailboxId is the lineage fold, not hash(flat_name), and replies still route back to the spawner. The text after each : is that node's discriminator; / carries the ancestry. Spawn stays within the module the instance runs from; a different binary comes in through load_component, which carries its own code and kind vocabulary. This is what lets a wasm crate be a library of actors: a UI root spawns its panels, a zone manager spawns a per-entity actor for each thing in range — all inside the one resident instance, while each child keeps its own state.

A Spawn { parent } places an instanced type beneath a live parent whose type it declares child_of, at parent/NS:key; a singleton names no parent, so a parent naming one is refused. In SubstrateHarness, the door for this is spawn_child::<P, C>(&parent, key), which takes the parent's already-proven reference; the MCP spawn tool takes the same shape as parent?.

Hot reload

Every component can already be replaced in place — its wasm swapped behind a stable mailbox without a drop. State continuity across that swap rides two WasmActor hooks, on_dehydrate and on_rehydrate, that default to no-ops (ADR-0101). Override them to carry state forward: on_dehydrate serializes the old instance's state, and on_rehydrate recovers it on the replacement. There's no replaceable flag on export! and no subtrait — participation is the override itself. on_rehydrate's ctx is typed by the actor like a handler's (WasmCtx<'_> reads as WasmCtx<'_, Self>). Both hooks return Result<(), ActorInitError>, as init and wire do, and the save verbs return the save's error for the hook to pass on with ? (ADR-0249 §1):

#![allow(unused)]
fn main() {
#[actor(root)]
impl WasmActor for MyComponent {
    // init / wire / #[handler::<class>]s as usual …

    fn on_dehydrate(&mut self, ctx: &mut WasmDropCtx<'_>) -> Result<(), ActorInitError> {
        ctx.save_state_kind::<Snapshot>(0, &snapshot)?;   // hand state to the successor
        Ok(())
    }

    fn on_rehydrate(&mut self, ctx: &mut WasmCtx<'_>, prior: PriorState<'_>) -> Result<(), ActorInitError> {
        let snap = prior.decode_kind::<Snapshot>().ok_or("the saved snapshot does not decode")?;
        …
        Ok(())
    }
}

aether_actor::export!(public = [MyComponent]);
}

A returned error is how a replace hook says no, and a trap is a bug:

  • on_dehydrate returns an error: the republish is refused naming the hook, and the old instance keeps running. It is handed back whatever it saved before the error, through its own on_rehydrate. on_dehydrate saves and does not send: WasmDropCtx has no send verb, and unwire is the hook that announces a departure.
  • on_dehydrate traps: the engine aborts. The old instance is the live guest, and a guest that trapped runs no more code.
  • A successor's on_rehydrate returns an error or traps: the republish is refused naming the hook, and the successor is dropped.
  • The old instance's on_rehydrate returns an error when it is handed its state back after a refused republish: the instance closes. Each reply it holds is answered unanswered and its name is spent. A trap there aborts the engine.

The hooks #[actor] generates for a declared type State return the save's result and the decode's, so a successor whose type State kind changed shape refuses the republish. An author who wants a fresh start on a mismatch writes both hooks by hand and returns Ok(()) when decode_kind answers None. A resident inline child the successor cannot rebuild refuses the republish the same way: an unknown type, a placement the successor rejects, a config that no longer decodes, a failed init, or the child's own on_rehydrate error.

A Publish { code, configs } of a module that succeeds one already bound republishes every live instance of the module's namespaces as one group (ADR-0241 §7). Its members are every live instance of every namespace the module publishes; each keeps its hosted type, since the namespace is the type. Identical bytes answer Ok with no swap. Otherwise the host first runs every pre-check and refuses the whole republish, naming each instance a check refuses, before any member is touched:

  • a content-addressed module, or one whose republish is already in flight;
  • a module that declares a boot, or succeeds one that did (ADR-0147; "a boot module is not replaceable");
  • a module publish admission refuses (see Publishing a module): a dropped namespace, or a row or #[fallback] a namespace or private child type narrows (ADR-0231 §5);
  • a live instance, a member or an inline child one rebuilds, whose type adds a dependency with no Live route (ADR-0230);
  • a config: an instance whose type's config kind changed needs a configs entry { path, config }, decoded strictly as the new kind; an unlisted instance with an unchanged kind keeps its stored spawn config; a live inline child of a type whose config kind changes cannot be given one.

A module with no predecessor — none of its namespaces is yet published — is not refused: it binds as a first publish.

Then each member prepares: its inbox gate closes, so mail for it waits; the candidate instantiates behind the same binding with its outbox held, the old guest runs on_dehydrate and is kept, the correlation cursor, reply table, request contexts and watches move to the candidate, and it runs on_rehydrate and then wire, still with its outbox held. Once every member is ready the module publishes, and each member commits: the old guest runs unwire, whose mail leaves at once, then drops, and the candidate's held mail leaves on a chain of its own, with the mail its gate queued reaching the candidate in order. PublishResult::Ok { types }, each republished type with its capabilities, comes once every commit's chain has settled. A component that leaves both state hooks at their defaults swaps cleanly and comes back fresh from init. Resident inline children are rebuilt from the module's exported types and its export! private list, each under its old alias, and wire after their parent's wire; the spawns(..) check guarantees that list names every child a listed actor can spawn inline.

A refusal in any member's prepare (a failed init, an on_dehydrate that returned an error, a rejected state save, a carried context, a request's or a watch's, that the candidate does not declare (ADR-0139 §4), a failed rehydrate, or a failed wire), or a refused publish, aborts every member: each unwires its candidate exactly when that candidate wired, discards it, reinstates its old guest, still wired, with its reply table, counters and watches, hands it back the state its on_dehydrate saved through its on_rehydrate, with no second wire, and receives the mail its gate queued; nothing a candidate sent leaves. Only teardown outside that saved state and outside what wire built is not undone (ADR-0016 §4), so an on_rehydrate override should assign from prior rather than accumulate onto what the instance already holds. While a republish is in flight, a load of one of its namespaces and a drop of a member wait for the answer and then run against the code that won; a republish that arrives while a load of its namespaces is in flight waits for those births.

A component's watches on other actors stand across a republish with no code from its author (Watching another actor). The host moves each registration with the mailbox, and each watch's context rides the saved state whether or not the component overrides on_dehydrate, so the successor's handler runs with the context its predecessor stored, under the id its predecessor was given. A watched actor that closes while the component is prepared is reported to the guest that wins: its notice waits at the inbox gate with the rest of the mail. A successor's wire that watches finds the standing watch and takes its id, and an aborted republish never doubles a watch: the old guest never unwired, so it is still wired and runs no second wire. A successor whose handler for a watched type takes another context kind is refused when it no longer declares the old kind; one that still declares it gets an error in its log ring at the notice, and its handler does not run.

The load-bearing property is binding stability (ADR-0038): each swap replaces the wasm Module in place behind a stable mailbox handle, so the mailbox id, any route cache, and existing input subscriptions all stay valid across the swap. Peers mailing the component never learn it changed. Prefer save_state_kind (which carries schema identity through the kind system) over the raw save_state byte bundle unless you're persisting a non-kind blob or driving an explicit migration.

Declared dependencies

An actor that cannot run without other actors lists them in one depends(...) on #[actor] (ADR-0230):

#![allow(unused)]
fn main() {
#[actor(root, depends(RenderCapability, ParentPeerTarget))]
impl WasmActor for MeshViewer {
    // …
}
}

Each entry names a root singleton (One), a chassis capability or a loaded guest alike: a guest singleton is named at the root by its published namespace (ADR-0241 §5), so a dependency reads the same position from every caller. A keyed (Instanced) entry is a compile error: which instance is meant is run-time data, and that instance is reached through the reference its spawn returned. The macro emits the actor's one Declared impl, whose Depends lists the entries, and an impl DependsOn<R> for each entry that names R's position in that list. That list is what the checks read: export! writes the inputs section's Dependency records from the actor's Declared::Depends, so the host reads the declaration without running the guest, and a native actor's birth check walks the same list. An actor written without #[actor] writes its own Declared impl and is checked by what it lists. A hand-written DependsOn<R> for an undeclared R repeats an emitted impl (E0119) or names a position that holds another dependency or none (E0277), so no proof is minted for a dependency the host never checks (ADR-0231 §10). An entry of a public actor is declared pub, in a private module if it must stay out of other crates' reach. A second depends(...) in the same attribute is a compile error: every dependency goes in the one list.

Dependencies are checked where an actor stands up, and only there (ADR-0241 §4). Publishing a module stands nothing up, so the checks sit at the four stand-up sites:

  • a load checks the actor it stands up;
  • a module boot checks the boot actor;
  • a republish checks each live instance it rebuilds, loaded or inline, for a dependency its successor type adds;
  • an inline spawn checks the child's type.

At the first three, an actor whose declared dependency has no Live route is refused before init — the operation replies its Err naming the actor and the missing namespace ("<actor> depends on <namespace>, which is not live"), and a refused replacement keeps the running module. There is no ordering, retry, or wait: two actors that declare each other both refuse. For two actors that mail each other both ways, one declares the other and the other keeps the envelope sender of its announcement; see Addressing a peer you cannot depend on.

An inline child, exported or listed under export!'s private = [..], is checked when it is spawned. A dependency with no Live route refuses the spawn before the host allocates its alias, so the child's init never runs: the verb returns SpawnError::DependencyNotLive, and the host's warning names the child and the missing namespace. The module itself loads whatever its inline children declare, so a module is never refused over a child it may never spawn. A republish that adds a dependency to a type with no live instance proceeds, and the dependency is checked at the type's next spawn.

Addressing an actor by path

A declared dependency reaches only an actor whose type the guest can compile and name. A native actor in a native-only crate, such as a Bloomery engine's journal owner or bundle driver, is named to a guest only by the marker type its identity half publishes (JournalActor, BundleDriver), and an Instanced actor cannot be declared at all. A guest reaches such an actor by its path instead: the operator names it in the component's config as an ActorPath<R>, whose leaf must name R to decode, and the guest proves it once with ctx.resolve(&path) (ADR-0230 §3, #7205):

#![allow(unused)]
fn main() {
fn wire(&mut self, ctx: &mut WireCtx<'_, '_>) -> Result<(), ActorInitError> {
    let journal = ctx
        .resolve(&self.config.journal)
        .map_err(|error| ActorInitError::new(format!("the journal does not prove: {error}")))?;
    self.journal = Some(journal);
    Ok(())
}
}

The verb folds the path as written — a typed path is canonical by construction, so no short-path expansion runs — and finds the Live route standing under exactly that canonical name, the same read the native NativeCtx::resolve (over a ProtocolPath<P>) takes, so both answer the same way. It refuses one way, naming no position:

RefusalWhen
ResolveError::NotLive { path }no Live route stands under the path's canonical name: never registered, still Starting, Dropped, or a fold collision with a different canonical name

It costs one host call reading the published route view. Call it once, at wire (a WireCtx derefs to WasmCtx) or at receipt, and keep the reference it returns; never re-resolve at a send. The verb is on the receive and wire ctx only, not on WasmInitCtx. The guest decides what a refusal means: returned from wire, as above, it fails the load with that message; handled, the instance goes live without the peer.

The answer is kind-checked: ctx.send_to(journal, &kind) compiles only for a kind R handles, caught where the send is written rather than at the recipient. A path the guest will send to arrives as this typed path, not an ErasedActorPath (R-0040).

A guest handed text with no compile-time actor claim — over the wire, or a short path that only expands against the live registry — proves it instead with ctx.resolve_path(&path), the guest twin of the native NativeCtx::resolve_path, over an ErasedActorPath. Its answer is an ErasedActorRef, which names no actor type: it serves identity (naming what a path resolves to) and the guard cast (ctx.cast, over a reference it proves), never a send target. It refuses in two ways, and neither names a position:

RefusalWhen
ResolvePathError::Unresolved { detail }the path names no Starting or Live route, a dropped route included; detail is the registry's refusal as text
ResolvePathError::NotLive { canonical_path }the route is still Starting, or drops between the two reads

Any loaded component can reach any Live actor whose path it can spell, so a native actor that must not take guest mail cannot rely on its path being unknown.

Naming a peer by protocol

An ActorPath<R> makes the guest depend on the crate of one actor type. A guest that needs only "an actor that handles protocol P stands here" takes a ProtocolPath<P> instead, in its config, in mail, or in an inline child's config (ADR-0231 §3, #7501). A kind that carries one is declared no_serde, since the path decodes only against an engine. A scene that draws from whatever publishes a view names it this way and never depends on a camera crate:

#![allow(unused)]
fn main() {
#[aether_data::kind(name = "example.scene.config", default, no_serde)]
pub struct SceneConfig {
    /// The actor to take the view from. `None` only in the compiled default.
    pub view: Option<ProtocolPath<ViewSource>>,
}

fn wire(&mut self, ctx: &mut WireCtx<'_, '_>) -> Result<(), ActorInitError> {
    let Some(path) = &self.config.view else {
        return Err(ActorInitError::new("the scene's config names no view source"));
    };
    let source = ctx.resolve(path).map_err(|error| ActorInitError::new(error.to_string()))?;
    ctx.send_to(source, &ViewSubscribe);
    self.source = Some(source);
    Ok(())
}
}

Two proofs run, as they do for a native actor. The decode proves the path's type: that the route standing under exactly that path, Live or closed, published every row of P. ctx.resolve then proves liveness and mints the ProtocolRef<P>; it compares no rows.

Where it is refusedRefusalWhen
decodeUnpublishedno route has stood at the path, or its actor is still starting
decodeUncovered { kind }the route does not publish the protocol row kind
resolveResolveError::NotLive { path }the path proved, and its actor has closed

A config that does not decode refuses the load before init runs, and the load's error names the actor, the config kind, the path, and the reason:

example.scene: config `example.scene.config` did not decode: aether wire: protocol path `aether.kit.camera:main` refused: no route has published at this path

So the target is loaded before the component whose config names it; a component's own path, and any actor born after it, does not prove. An inline spawn whose config does not decode returns SpawnError::InitFailed with the same text. A request whose path does not decode is answered through its reply's From<PathRefused>, and its handler does not run.

Each ProtocolPath<P> field costs one host call per decode, which reads the route's rows into guest memory; nothing is cached. That suits a config, an attach, or a subscribe kind. A kind sent every frame carries no path: resolve once, keep the ProtocolRef<P>, and send through it. A kind with no ProtocolPath field makes no host call.

A guest can load a component itself. It declares the component host, depends(ComponentHostCapability), sends it aether.component.load, and keeps the ctx.sender() of its response handler for LoadResult::Ok, which is the loaded actor, as its reference to the component, as Publishing, spawning, loading, and dropping describes. The loaded component has no door back to its loader; Talking back to the actor that loaded you shows the two shapes that reach it.

The worked example is the environment bootstrap script, crates/aether-bloomery-bootstrap: its wire resolves the typed ActorPath<JournalActor> and ActorPath<BundleDriver> config fields with ctx.resolve, logs one error and stops on a refusal, and otherwise drives an import, merge, and publish sequence through the two kind-checked references (see Building an environment).

Requiring something of the sender

A guest that will mail its sender back states what the sender must handle on the handler that takes the first mail, as its ctx's sender, the second type argument (ADR-0231 §11):

#![allow(unused)]
fn main() {
#[handler::request]
fn on_dial(&mut self, ctx: &mut WasmCtx<'_, Self, Consumer>, dial: Dial) -> Dialed {
    let consumer = ctx.sender(); // ProtocolRef<Consumer>
    self.consumers.push(consumer);
    ctx.send_to(consumer, &Ready { tag: dial.tag });
    Dialed::Ok
}
}

#[handler::tell] and #[handler::request] state one. A component that sends Dial writes the plain ctx.send::<Dialer>(&Dial { .. }), which builds only when the component has a handler for each of Consumer's kinds. The engine casts the sender to Consumer before on_dial runs, so ctx.sender() there is a ProtocolRef<Consumer>, with no Option and no ctx.cast. A handler that states nothing keeps Anyone, whose ctx.sender() is an Option<ErasedActorRef>. A sender that reaches the guest another way and does not cover the protocol never runs the handler: a request is answered with the reply's From<PathRefused>, naming the sender, and a tell is refused, logged in the guest's log, and reported to a caller relayed through aether.rpc.server. The bundle fixture's SenderGate (crates/aether-test-fixtures-bundle/src/sender_gate.rs) is the worked example. See Reply classes for the rule on both transports.

Watching another actor

A component that keeps state on behalf of another actor needs to learn when that actor is gone, whether or not it said so on its way out. ctx.watch does that for any typed reference the component holds (ADR-0079 §8): when the target closes, by a drop, a trap in its own unwire, or engine teardown, the component's handler for the type it was watched through runs once.

#![allow(unused)]
fn main() {
/// What the ledger notes about each provider it holds rows for.
#[aether_data::kind(name = "example.ledger.provider_note")]
pub struct ProviderNote {
    pub last: u32,
}

#[handler::tell]
fn on_admit(&mut self, ctx: &mut WasmCtx<'_>, admit: Admit) {
    let Some(sender) = ctx.sender() else { return };
    let Some(provider) = ctx.cast::<Provider>(sender) else { return };   // ProtocolRef<Provider>
    let watch = ctx.watch(provider, ProviderNote { last: admit.id });    // the same id on every mail from this provider
    self.rows.entry(watch).or_default().push(admit.row);
}

#[handler::event]
fn on_provider_gone(&mut self, _ctx: &mut WasmCtx<'_>, event: Departed<Provider>, _note: ProviderNote) {
    self.rows.remove(&event.watch);          // event.actor: ProtocolRef<Provider>
}
}

The pattern is: cast the sender to a protocol and watch it on every mail, key the component's own table by the returned WatchId, and remove by event.watch in the handler. The cast and the watch are one host call each.

  • The target is a typed reference. An ActorRef<R> watches through the actor type, and a ProtocolRef<P> through a protocol, such as a sender cast at receipt. An ErasedActorRef is cast first.
  • One handler per watched type. The handler is a #[handler::event] whose third parameter is Departed<W>, and ctx.watch compiles only for a type the actor has one for. event.actor is the departed actor as the reference it was watched through, for identity, an in-memory key, or a log line; event.watch is the id watch returned.
  • One context kind per watched type. The handler's fourth parameter is the context every watch of that type passes; a watch with another kind does not compile. A component that keeps notes of different shapes about actors of one type declares one enum kind for them. The context is never Option<C>, since a watch always stores one.
  • NoContext for nothing to note. A handler that leaves the fourth parameter out is watched for with ctx.watch(camera, NoContext).
  • A watch is unique per target and watched type. Watching a target that is already watched through the same type makes no second watch: it returns the same WatchId and replaces the stored context with the one passed. So the id is the component's own stable name for the target. One actor watched through two types is two watches with two ids, and its departure runs each handler once.
  • The id stays with the component that holds it. A WatchId names a row in its holder's own watch table and is meaningful only to that actor: the same number in another actor's hands names one of that actor's own watches. It has actor reach, so a kind with a WatchId field is never mail and does not compile as a handler's kind or a send's argument, and no component builds one from a number. It may sit in the component's saved state and in a context it stores with a request. Two actors that must speak of one watch use a name of their own, such as a key the watcher keeps beside the id.
  • It never fails. A target that had already closed is noticed the same way. The notice is mail, handled after the handler that watched returns, so what that handler keyed on the id is in place.
  • A watch ends at its notice, or at ctx.unwatch(id). A notice already posted when unwatch runs finds no watch and runs no handler. Watching the target again afterwards is a new id.
  • The host releases every watch when the component closes, fails or traps in wire, or is dropped as a republish candidate, with none of its code run, unless its wire ran — then unwire. An inline child watches from its own wire like any actor and hears its own notice.

watch and unwatch are on WasmCtx, so a handler, wire, unwire, and on_rehydrate can call them; init and on_dehydrate cannot. A watch stands across a republish of the watcher (Hot reload). A native actor watches with ctx.monitor, keeps the MonitorHandle, and takes MonitorNotice.

Where to read more