Mail, kinds & scheduling
Governing ADRs: ADR-0002 (mail-first actor model), ADR-0005 (mail typing), ADR-0019 (unified encoding), ADR-0087 (burst dispatch + the ordering spine). The mail/kind model is stable; the scheduler internals (how work is batched and balanced across threads) are settling — this page documents the stable contract and defers the guts to ADR-0087.
This is the spine the rest of the engine hangs on. Actors don't call each other — they send mail. A piece of mail is a typed payload (a kind) addressed to a mailbox, and the scheduler runs the handlers. Understand this page and every other subsystem reads as "an actor that receives some kinds."
The cast, in one breath. Everything in the engine is an actor — a unit
that owns some state and talks only by mail. Two kinds: a capability is a
native actor compiled into the substrate (render, audio, file I/O, and the
rest), and a component is an actor loaded at runtime across the engine's
FFI boundary (the WasmActor ABI) — your logic, and the thing that gets
hot-swapped. The split is built-in-native vs loaded-guest, not a specific
language: the FFI boundary is target-agnostic by design, and WASM is simply
the only guest target wired up today. So a component is WASM right now, but a
C / C++ / other guest that speaks the ABI is the same category. All of them are
one actor model and address each other identically. When this page says
"component" it means a loaded FFI actor; "actor" means either kind. Deep dive:
Components & lifecycle.
Why it exists
Aether could have let subsystems share memory and coordinate through a scheduler. It deliberately doesn't (ADR-0002). One uniform message boundary buys four things the project treats as non-negotiable:
- Hot-swap. A component can be unloaded and reloaded mid-run without the host contortions shared state would require — because nothing else holds a pointer into it; it only receives mail.
- Sandboxing. Agent-authored code can't be trusted with shared memory, so it runs as a loaded component whose only channel is mail — its reach is exactly the mailboxes it can address, nothing more. The current guest target, WASM, backs that with a real memory sandbox (its own linear memory, traps contained), which is a large part of why WASM is the target wired up first.
- Capability equals reachability — and so does observability. There is no privileged side-channel, not even for an operator agent: what an actor can do is exactly what it can mail, so gating a build is just choosing which mailboxes are registered. The payoff on the flip side is the point, not a side effect — because every function in the engine is reachable as mail, you can interpose anywhere in the stack: address any mailbox, watch any exchange, inject a message mid-flow to reproduce or debug a state. Universal reachability is what makes the running engine inspectable by the agent driving it, and it is a primary reason the model is shaped this way.
- Location independence. Transport is a choice the recipient doesn't have to reason about: mail from a sibling actor in the same process and mail from an agent over the MCP wire run the same handler path, so the "in-process SDK vs out-of-process server" question dissolves (ADR-0002/ADR-0006). A handler isn't fully blind to provenance — it can see a reply target and sender lineage (ADR-0083) — but the guarantee is that correctness and capability never depend on where mail came from. (Pinning down the exact origin, e.g. for a security policy, isn't first-class today; it's a plausible future.)
A note on granularity. Actors span a wide range of sizes. A coarse actor
can own a whole subsystem — the entire physics world, state as plain data and a
tight inner loop. A fine one can be a single instance: instanced actors are
a first-class category (cardinality — Singleton vs Instanced — is its own
axis, ADR-0079), and the burst dispatcher (ADR-0087) is built to rip through
large sets of mail, so fan-out across many small actors is cheap, not the
performance trap an earlier design assumed. ADR-0002's "subsystem-sized, never
per-entity" rule predates both and is superseded on this point.
When do you split into instances versus manage multiplicity inside one actor?
Reach for an instanced actor when each instance needs its own lifecycle and
isolation — one actor per TCP session is the canonical case (a NetCapability
listener spawns a SessionActor per connection, each able to drop
independently); per-session game logic and per-monster AI are the same shape.
Keep it a single actor when the multiplicity is just internal state — the
camera component drives many cameras from one actor, no per-camera mailbox
needed.
Fine granularity is cheap because actors don't each own a thread — they're multiplexed onto a shared work-stealing scheduler (ADR-0087), and the run-token that lets only one worker run a given actor at a time is what gives you the single-threaded-per-actor property. So thousands of instanced actors cost mail and state, not threads. The rare exception is blocking I/O: an actor that must make a blocking call may spawn a dedicated thread for that (a listener's accept loop, a blocking provider call), but that's specific to blocking I/O, not how actors run in general — see the no-blocking note under Scheduling.
What it does
Mailbox vs kind — the distinction to internalize first. The mailbox is
the address (where mail goes); the kind is the payload shape (what the mail
is). They're independent — but they share a naming convention, and that's the
source of the confusion: a kind name is usually its mailbox's prefix plus a
verb (aether.audio + .note_on → aether.audio.note_on). So the kind reads
like a more specific mailbox, and the reflex is to use it as the address. The
trap, concretely: sending kind aether.audio.note_on to recipient
aether.audio.note_on instead of to aether.audio. No mailbox has that name,
so it silently warn-drops (see the addressing rules below). The mail routes by
mailbox; the kind only describes the bytes. When something you sent seems to
vanish, this is the first thing to check.
A kind is a typed, self-describing payload. It's a Rust type deriving
Kind + Schema with a #[kind(name = "…")], carrying a stable hashed
KindId and a schema that describes its bytes (ADR-0005/ADR-0031). Because a kind
carries its own schema, the wire layer can encode it from JSON and a recipient
can decode it without a shared header — and an agent can ask a live engine what
kinds exist (describe_kinds).
Encoding is schema-driven (ADR-0019). On the way in, params are encoded to wire bytes against the kind's schema; the recipient decodes those bytes back into the kind per-kind. The bytes stay opaque until the addressed handler decodes them — nothing in the middle needs to understand the payload.
A kind says Vec3 where it means a vector. The aether-math value types
are schema types, so a kind field is spelled Vec2, Vec3, Vec4, Quat,
Mat4, Aabb or Rect2 (and Rgb, Rgba, Hsl for a colour), never a bare
[f32; 3]. A pod kind may hold them too: each is #[repr(C)] and all f32.
On the wire a math field is its f32 components in declaration order, four
little-endian bytes each, with no tag or length, so a Vec3 is 12 bytes and a
Mat4 is 64, column by column. In params JSON it is an object of the
component names:
| Type | JSON form |
|---|---|
Vec2 / Vec3 / Vec4 | {"x": 1.0, "y": 2.0}, plus "z", plus "w" |
Quat | {"x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0} |
Mat4 | {"cols": [<Vec4>, <Vec4>, <Vec4>, <Vec4>]}, column-major |
Aabb / Rect2 | {"min": <Vec3>, "max": <Vec3>} / {"min": <Vec2>, "max": <Vec2>} |
Rgb / Rgba | {"r": 1.0, "g": 0.5, "b": 0.0}, plus "a" |
Hsl | {"h": 0.0, "s": 1.0, "l": 0.5} |
The same types are fields of a stored type
(ADR-0059):
each derives aether_data::StorageLeaf, so it is stored as one record, and a
#[storage(validate)] newtype over one (struct Radiance(Rgb)) is how a kind
carries an invariant such as "finite and not negative". A value type of your
own opts in the same way, with #[derive(Schema, StorageLeaf)].
Mail is fire-and-forget by default. A handler promises nothing about a reply. If a reply matters, that's a separate, explicit contract between the two kinds — never an implicit "every kind has a response." Don't write a caller that blocks waiting for a reply a handler never agreed to send.
When several requests are in flight, carry a typed request context. A
one-shot request that needs to match a later reply should call
send_with_context(&request, context) and recover the bookkeeping in the
reply handler with ctx.take_context::<Context>(). The context moves into the
table, so a guest's context may hold a Held<R> deferred reply
(ADR-0243):
storing parks it and the take claims it back. Contexts are Kinds, so the
table stores schema-typed bytes, checks the KindId on take, and rides guest
dehydrate/rehydrate automatically. A context holding a reply handle or a
sender route has actor reach (ADR-0242): the table stores it, but it is never
mail, so no actor can send it and no handler receives it. A replacement that no longer declares a
carried context's kind (a changed schema is a new KindId) is refused, and the
old guest keeps the context. A take of the wrong type returns None and
leaves the context stored, so a reply handler that serves several context kinds
tries each type in turn. Keep contexts small: stash routing
bookkeeping such as the caller's reply handle, origin, or parse mode; keep bulky
domain state in actor fields and put only an id in the context. The table never
drops a context: it grows past its preallocated room and logs a warning at each
new high-water mark, so a peer that never replies shows up in the actor's log.
A reply handle kept in a context stays answerable across a republish: the mailbox's
pending replies move to the replacement with it. Only an unchecked
(#[handler::unchecked(reason = "…")]) handler can read and keep its raw reply handle; a request, tell, event, or response
handler's handle is freed when it returns, unless the handler returns Pending<R> and keeps the
typed Held<R> that answers it. A component whose held handles keep growing logs a warning
naming it.
The lower-level send_tracked(&request) / ctx.in_reply_to() pair is still
available when the request id itself is the domain key. in_reply_to() returns
None for ordinary inbound requests, uncorrelated mail, and inline-cluster local
dispatches that never crossed the host envelope boundary. Echoed payload fields
such as the echoed addr on aether.fs.read_result remain useful domain
context, but exact duplicate-safe matching belongs to the request context or
envelope request id. Detached data phases still carry their own domain-level
correlation in their payloads.
The ordering spine (ADR-0087) — the single contract to hold in your head when writing handlers. Get it wrong and you write code that passes in dev and breaks under load:
Same sender, same recipient → arrives in send order (per-recipient FIFO). Different recipients → no ordering guarantee — each send is an independent async call, like hitting a server.
In practice: if you send(A) then send(B) to the same actor, B is handled
after A — rely on it. If you send A to actor X and B to actor Y, you know
nothing about their relative order; Y might finish before X starts. So
never encode a cross-actor sequence by send order. When B must happen after
A across actors, make it causal: have A's handler trigger B, or have the
actor that needs the order receive both and sequence them itself. Strict
cross-recipient ordering is deliberately not offered — it would let one slow
recipient stall everything queued behind it in a fan-out.
Scheduling, at the level you can depend on. Each actor is single-threaded
from its own perspective — its handlers never run concurrently with each
other, so actor state is plain fields (no locks, no RefCell). The scheduler
runs handlers cooperatively across a thread pool.
A handler must never block its thread. No sleeping, no blocking I/O, no waiting on a blocking lock or channel, no busy-spin. Because scheduling is cooperative, a blocked handler doesn't just stall itself — it pins a worker doing nothing and can deadlock a reply chain (the actor you're waiting on may have its next mail queued behind you). A long compute handler is tolerated (it ties up only its own worker), but anything that waits on the outside world is not: await a reply through the framework so the scheduler can run other work meanwhile, or hand heavy/async work off the actor thread entirely through the sanctioned offload primitives. The full treatment — the await/hold primitives and how to reason about concurrency here — is in Concurrency and blocking. The rule to carry now: don't block in a handler.
How mail is batched and balanced across workers (the per-producer rings, the work-stealing pool, the burst-as-unit-of-dispatch) is drawn out on The scheduler, with ADR-0087 as the authority while the internals settle — build on the contracts above, not the internals.
How to use it
From an agent (over MCP). send_mail takes
{engine_id, address, kind_name, params} — address is the mailbox,
kind_name is the payload shape, params is JSON encoded against the
kind's schema. An Option field may be left out of params and encodes as
None; every other field is required. For a batch that must settle as one
traced unit, use
send_mail_traced. The active MCP tool schema is authoritative for arguments;
the harness explains the operating model.
From inside a component. Declare the peer with
#[actor(depends(RenderCapability))], then send to it by type on a ctx typed
by the actor (WasmCtx<'_, Self>): ctx.send::<RenderCapability>(&kind). The
turbofish names only the recipient; the kind is inferred from the payload, and
the send compiles only when the actor declares that recipient and the recipient
handles the kind. The siblings are send_many (one cast batch),
send_tracked (returns the request id), and send_with_context (stores a
typed context for the reply handler). A typed proof the actor holds, an
ActorRef<R> or a ProtocolRef<P>, sends with ctx.send_to(reference, &kind),
checked against the kind; sending through an erased reference is being
removed (#6895). Kind::ID and the typed resolver are
compile-time constants, so there's no host round-trip to resolve an address. You receive mail with a
#[handler::<class>] fn on_x(&mut self, ctx, mail: K) — the kind is inferred from the
third parameter (see Components & lifecycle and the Writing a component
recipe).
Addressing rules that bite if ignored:
- Chassis mailboxes live under
aether.<name>(aether.render,aether.fs,aether.audio,aether.lifecycle,aether.window,aether.component). - A loaded component registers at its published name —
NS,NS:key, orparent/NS:key(ADR-0241 §5) — use the full addressLoadResult.pathhands back. - Bare names (
"camera","player") are not registered and warn-drop silently. If mail seems to vanish, check the address first. - Never hash a name into a
MailboxIdyourself. The name hashes are private toaether-data, a written path resolves through the host registry, which folds it node by node, andMailboxId::from_name, the one hash spelling left, is disallowed byclippy.toml, because a hand-computed address freezes the target's registration shape into the caller. Boot and driver code that has no ctx to resolve through still doesn't need them:aether_actor::root_mailbox::<C>()returns a root-pinned capability's mailbox, and itsC: Root+Resolver = Onebound is a compile error for an actor whose address is only knowable under a caller's lineage.
Naming a kind
A kind name is a wire contract, not a label. Kind::ID is
fnv1a_64_prefixed(KIND_DOMAIN, canonical(name, schema)), so renaming a kind
mints a different id and every peer that already routes on the old one stops
matching. You get one shot at the name; spend it under this grammar.
aether.<family>[.<sub>].<verb>_<noun> command
aether.<family>[.<sub>].<noun>_<past> event
aether.<family>[.<sub>].<verb>_<noun>_result reply
aether.<family>.config capability boot config
- The family segment is mandatory. A name is at least
<root>.<family>.<leaf>.aether.render.destroy_textureis a kind;aether.draw_triangleis a family with no room to grow a sibling. - Commands lead with the verb.
create_texture,create_font,set_master_gain. A bare verb leaf is fine where the family already supplies the noun —aether.fs.read,aether.fs.write— but the verb still leads. - Events read
<noun>_<past participle>:aether.tcp.session_closed,aether.component.module_published. Carrying the noun is what keeps an event clear of the command that causes it.aether.window.closeagainstaether.window.closedis the shape to avoid: one letter apart, and a typo in either direction routes silently to the wrong kind. _resultis the only reply suffix. Not_reply,_ack,_report,_complete, or a bare.responsesegment. A reply is<request>_resultand nothing else, so a caller can name the reply from the request without a lookup..configis the only config suffix. A capability's boot config (ADR-0090) isaether.<family>.config— its own segment, never a<something>_configleaf.- Segments are lowercase
snake_case. A dash appears only where it names a dash-sibling namespace (aether.kit.camera-controller.config), never as a word separator inside a segment.
The two families to copy are aether.fs — six bare verbs under one family,
each paired with <verb>_result — and aether.store, two dozen
<verb>_<noun> / <verb>_<noun>_result pairs with no third spelling anywhere
in it.
The grammar is enforced as a test, not as a lint or a macro:
crates/aether-kinds/tests/kind_name_grammar.rs scans every
#[kind(name = "…")] declaration in crates/ and checks the four mechanical
rules above (family segment, segment casing, reply suffix, config suffix). It
runs on every pull request that changes a crate, whether or not that crate is
related to aether-kinds in the dependency graph.
Names that predate the grammar are listed in
crates/aether-kinds/tests/kind_name_allow_list.txt; the test fails on a
violator that is not on that list and on a list entry that is no longer a
violator, so the file only ever shrinks. Fixing an entry means renaming a kind,
which means changing its id — a deliberate per-family decision, not a drive-by.
How to extend or reuse it
The mail spine is the thing you extend through, so most extension is "teach the system a new kind" or "stand up a new mailbox":
- A new message shape → add a kind. See the Adding a substrate
kind recipe: define the type in the
right kind crate, derive
Kind/Schema, add a handler — the descriptor that surfaces it on the MCP wire registers itself. Name it under Naming a kind before you write the struct. - A new mailbox → stand up an actor to own it. A native one is a chassis capability (the Adding a chassis capability recipe); a wasm one is a component (the Writing a component recipe). Either way it's the same actor model and the same addressing.
- Reuse the streams rather than polling. Tick is subscribed through
lifecycle; key, mouse, and window-size events are subscribed through
WindowCapabilitywithWindowSelector::One(id)orWindowSelector::All. Subscribe from a component'swirehook and receive the events as mail. See Input and File I/O.
Because everything is mail, these few moves compose: a new capability that handles a new kind and publishes another is the whole vocabulary of building on aether.