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

ContractLocation
Request/reply for one native capabilityaether-<cap>/src/kinds.rs
Component/guest public APIthat component's public rlib or sibling contract crate
Shared lifecycle/control/diagnostic vocabularyaether-kinds, with an explicit reason
Internal worker/task wake kindprivate 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:

OptionAddsUse for
eqPartialEq, Eqinteger/string payloads compared in tests and logic
partial_eqPartialEqfloat-carrying payloads, which cannot be Eq
copyCopysmall fixed-size payloads
defaultDefaultpayloads with a meaningful zero
podCopy, bytemuck::Pod, bytemuck::Zeroable; drops serdecast-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 verbatimHash, 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:

ReachMarkerIts bytes crossExample leaves
Actorneithernothing: they stay in their own actor's request-context tableReplyHandle, Source, SourceAddr
EngineCrossesActorsin-process mail between actorsMailboxId, MailId
WireCrossesActors and CrossesWirea wire Call, an MCP bundle, a fileprimitives, 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:

  • request for a typed return, and tell, event, or response for 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;
  • task completion 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

  1. Shape tests: encode/decode structured variants and reject malformed input at the owning data layer.
  2. Descriptor uniqueness: ensure no canonical-name collision.
  3. Handler test: exercise success and every meaningful error backend.
  4. Unsupported chassis: prove a bounded error when the public mailbox is installed without the resource.
  5. Live discovery: narrow describe_kinds to the exact name and describe_handlers to the owner.
  6. End-to-end mail: SubstrateHarness send_and_await_reply and 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.