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 whatdescribe_componentreads back to tell a live engine what the component accepts — your doc comments, filtered through a# Agentsection 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:
| kind | does | reply |
|---|---|---|
aether.component.publish | bind a module's namespaces (admission), register its kinds; a successor republishes every live instance of them as one group | PublishResult |
aether.component.spawn | stand up an instance of a published type at NS, NS:key, or parent/NS:key, or return a live one | SpawnResult |
aether.component.load | publish the module, then spawn the export it selects, in one call | LoadResult |
aether.component.drop | close the instance, which runs the guest's unwire; answers once the guest is released; its name tombstones | DropResult |
aether.component.unpublish | withdraw one published namespace; refused while any instance of it is live, naming it | UnpublishResult |
aether.component.list | enumerate the engine's live components | ListComponentsResult |
aether.component.describe | introspect one component's receive-side capabilities | DescribeComponentResult |
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.unpublishwithdraws 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_dehydratereturns 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 ownon_rehydrate.on_dehydratesaves and does not send:WasmDropCtxhas no send verb, andunwireis the hook that announces a departure.on_dehydratetraps: the engine aborts. The old instance is the live guest, and a guest that trapped runs no more code.- A successor's
on_rehydratereturns an error or traps: the republish is refused naming the hook, and the successor is dropped. - The old instance's
on_rehydratereturns an error when it is handed its state back after a refused republish: the instance closes. Each reply it holds is answeredunansweredand 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
Liveroute (ADR-0230); - a config: an instance whose type's config kind changed needs a
configsentry{ 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:
| Refusal | When |
|---|---|
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:
| Refusal | When |
|---|---|
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 refused | Refusal | When |
|---|---|---|
| decode | Unpublished | no route has stood at the path, or its actor is still starting |
| decode | Uncovered { kind } | the route does not publish the protocol row kind |
resolve | ResolveError::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 aProtocolRef<P>through a protocol, such as a sender cast at receipt. AnErasedActorRefis cast first. - One handler per watched type. The handler is a
#[handler::event]whose third parameter isDeparted<W>, andctx.watchcompiles only for a type the actor has one for.event.actoris the departed actor as the reference it was watched through, for identity, an in-memory key, or a log line;event.watchis the idwatchreturned. - One context kind per watched type. The handler's fourth parameter is the
context every
watchof 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 neverOption<C>, since a watch always stores one. NoContextfor nothing to note. A handler that leaves the fourth parameter out is watched for withctx.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
WatchIdand 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
WatchIdnames 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 aWatchIdfield 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 whenunwatchruns 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 itswireran — thenunwire. An inline child watches from its ownwirelike 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
- The actor this specializes — its lifecycle,
#[actor], handlers, addressing — The actor model. - The empty-crate-to-loaded walkthrough — crate setup, the actor block,
export!, the wasm build, loading, and the first round-trip — the Writing a component recipe. - Kinds, ids, and how the vocabulary crosses the wasm boundary — The type system.
- How mail routes and what a kind is — Mail, kinds & scheduling.
- Why a handler must never block, and how to wait instead — Concurrency & blocking.
- Loading and inspecting a live component — Component registry and replacement and the live MCP tool schemas.