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:

TypeJSON 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, or parent/NS:key (ADR-0241 §5) — use the full address LoadResult.path hands 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 MailboxId yourself. The name hashes are private to aether-data, a written path resolves through the host registry, which folds it node by node, and MailboxId::from_name, the one hash spelling left, is disallowed by clippy.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 its C: Root + Resolver = One bound 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_texture is a kind; aether.draw_triangle is 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.close against aether.window.closed is the shape to avoid: one letter apart, and a typo in either direction routes silently to the wrong kind.
  • _result is the only reply suffix. Not _reply, _ack, _report, _complete, or a bare .response segment. A reply is <request>_result and nothing else, so a caller can name the reply from the request without a lookup.
  • .config is the only config suffix. A capability's boot config (ADR-0090) is aether.<family>.config — its own segment, never a <something>_config leaf.
  • 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 WindowCapability with WindowSelector::One(id) or WindowSelector::All. Subscribe from a component's wire hook 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.