Adding a mail kind
Class: recompile. A kind is a public wire contract, so choose its owner and compatibility posture before writing the struct.
The current default is: the actor/capability that exchanges the kind owns
it (ADR-0121). aether-kinds is for genuinely substrate-wide vocabulary or a
documented upstream consumer that cannot depend on the owner.
1. Choose the owner
| Contract | Location |
|---|---|
| Request/reply for one native capability | aether-<cap>/src/kinds.rs |
| Component/guest public API | that component's public rlib or sibling contract crate |
| Shared lifecycle/control/diagnostic vocabulary | aether-kinds, with an explicit reason |
| Internal worker/task wake kind | private next to the runtime state |
Do not promote a kind merely so MCP can encode it. Live ListKinds reads the
engine registry, including dynamically loaded component kinds.
The clipboard contract is a compact current exemplar:
- kinds:
aether-clipboard/src/kinds.rs; - identity/helpers:
aether-clipboard/src/lib.rs; - system/in-memory backends:
aether-clipboard/src/runtime/mod.rs; - live discovery: inventory registry →
describe_kinds/describe_handlers.
2. Declare request and reply
#![allow(unused)] fn main() { /// Request the current UTF-8 clipboard text. #[aether_data::kind(name = "aether.clipboard.get_text")] pub struct GetClipboardText; /// Reply to `GetClipboardText`. #[aether_data::kind(name = "aether.clipboard.get_text_result")] pub enum GetClipboardTextResult { Ok { text: String }, Err { error: String }, } }
The canonical name is the operator-facing kind_name. Follow the owner's
family. Write docs for a caller, including reply, units, limits, and error
meaning.
#[aether_data::kind] declares the kind and emits the derive stack: Kind,
Schema, Debug, Clone, Serialize, Deserialize. Schema is what puts
the type on the mail path — it emits the structured wire codec (ADR-0188), and
the serde pair is there for the JSON/TOML edges, not for mail. Options name the
departures from that contract:
| Option | Adds | Use for |
|---|---|---|
eq | PartialEq, Eq | integer/string payloads compared in tests and logic |
partial_eq | PartialEq | float-carrying payloads, which cannot be Eq |
copy | Copy | small fixed-size payloads |
default | Default | payloads with a meaningful zero |
pod | Copy, bytemuck::Pod, bytemuck::Zeroable; drops serde | cast-encoded kinds — keep the #[repr(C)] above it, it is what makes the cast legal |
no_serde | — (drops Serialize/Deserialize) | kinds that never cross a JSON/TOML edge |
engine_only | — (withholds ActorMail) | engine-only mail the host alone sends, such as a departure or settlement notice (ADR-0233): no actor can send or reply it, and the raw-KindId doors refuse it |
derive(A, B) | A, B verbatim | Hash, PartialOrd, and other one-off needs |
A kind whose derive list this vocabulary cannot state exactly — no Debug, no
Clone, one half of the serde pair — keeps the explicit
#[derive(…)] + #[kind(name = …)] form. Both spellings produce the same
type; the attribute is shorthand for the common contract, not a replacement for
the derives.
Reply is a handler contract, not a Kind associated type. Name/result shapes
should make the pairing clear, but live handler inventory is what declares the
actual reply.
Reach
A kind's reach says where its bytes may go (ADR-0242), and it is the narrowest reach among its fields; nothing on the kind declares it:
| Reach | Marker | Its bytes cross | Example leaves |
|---|---|---|---|
| Actor | neither | nothing: they stay in their own actor's request-context table | ReplyHandle, Source, SourceAddr |
| Engine | CrossesActors | in-process mail between actors | MailboxId, MailId |
| Wire | CrossesActors and CrossesWire | a wire Call, an MCP bundle, a file | primitives, String, KindId, EngineId, paths, Blob |
The Schema and Storage derives implement each marker for a type whose
every field implements it, and the containers forward both. A hand-written
Schema impl adds the markers its type deserves beside it; without them, the
type has actor reach.
Every typed send and reply requires ActorMail, which the Kind derive
implements for a kind that crosses actors, unless the kind declares
engine_only. So a request context holding a ReplyHandle or a Source is
never mail, and no handler may receive it. A hand-written Kind impl adds
impl aether_data::ActorMail for T {} itself when actors may send the kind,
and its type must cross actors; without it, the kind cannot be sent at all.
The typed doors that cross the wire, a component's Config and the fleet
harness's typed sends, take WireMail, a kind of wire reach.
3. Re-export the marker surface
The owner module normally exposes kinds from its always-on/marker face:
#![allow(unused)] fn main() { pub mod kinds; pub use kinds::*; }
Keep the kind's dependencies wasm-safe when guest code must address it. Native
adapter/runtime dependencies belong behind the runtime feature, not in
kinds.rs.
#[derive(Kind)] submits a native descriptor automatically. There is no manual
master vector to edit. The live engine's registry and inventory expose it.
4. Add a handler class deliberately
#![allow(unused)] fn main() { #[handler::request] fn on_get_text( state: &mut Self::State, _ctx: &mut NativeCtx<'_>, _request: GetClipboardText, ) -> GetClipboardTextResult { match state.backend.get_text() { Ok(text) => GetClipboardTextResult::Ok { text }, Err(error) => GetClipboardTextResult::Err { error }, } } }
Use:
requestfor a typed return, andtell,event, orresponsefor none;- a single reply whose kind carries a list for a bounded repeated result;
unchecked(reason = "…")only when reply timing/type cannot be expressed as a return, with the reason saying which;taskcompletion for sanctioned off-thread work.
Do not copy an old low-level send_reply signature. Returning the reply from a
request handler preserves correlation and keeps handler inventory accurate.
If several chassis claim the same namespace, add the contract everywhere. An unsupported backend should return the ordinary error reply rather than accept mail and never settle.
5. Callers send the kind directly
A caller that declares depends(ClipboardCapability) sends the kind itself
through the flat verb:
#![allow(unused)] fn main() { ctx.send::<ClipboardCapability>(&SetClipboardText { text: text.to_owned() }); }
Keep the kind public; it is the whole sender surface. A capability adds no sender extension trait over the ctx (ADR-0232 §7): a helper method per kind would be a second way to send the same mail beside the flat verb.
6. Verify the boundary
- Shape tests: encode/decode structured variants and reject malformed input at the owning data layer.
- Descriptor uniqueness: ensure no canonical-name collision.
- Handler test: exercise success and every meaningful error backend.
- Unsupported chassis: prove a bounded error when the public mailbox is installed without the resource.
- Live discovery: narrow
describe_kindsto the exact name anddescribe_handlersto the owner. - End-to-end mail: SubstrateHarness
send_and_await_replyand decode the typed result.
Use FleetHarness only if the contract itself crosses RPC/process boundaries.
7. Treat schema edits as compatibility changes
The canonical kind id includes the canonical name/schema. A shape change can mint a different id. Rebuild every producer and consumer, including prebuilt wasm. Decide whether old and new forms must coexist for a migration window.
Check enum variants, optional fields, units, byte limits, and feature tiers—not only whether Rust callers compile together in one workspace.
When aether-kinds is correct
Some inventory, engine/component control, lifecycle, trace/log/cost, window driver, and utility contracts remain upstream because substrate/MCP/bundle consumers need them without depending on capability implementation. Document that dependency reason beside the family. Central placement is an exception with an owner, not the default for “native.”
Continue with Capability anatomy, The type system, and Inventory and transforms.