The type system

Governing ADRs: ADR-0005 (mail typing), ADR-0019 (unified encoding), ADR-0029/ADR-0030 (name- and schema-derived ids), ADR-0099 (actor identity and addressing), ADR-0031/ADR-0032 (const schema + canonical bytes / labels sidecar), ADR-0048 (transforms), ADR-0064/ADR-0065 (type-tagged wire ids + first-class id types). The type vocabulary is stable — the wire format depends on it.

Everything the engine moves is typed, and the vocabulary is small. Three kinds of thing carry types — kinds (payloads), mailboxes (addresses), and transforms (pure functions) — and each is named by a typed id. This page is the tour: what each one is, how it's identified, and how they compose.

The reason to care isn't compiler ergonomics. A typed thing here is self-describing: it carries enough to encode itself from JSON, decode itself without a shared header, and appear in the appropriate inventory. That lets an agent inspect live engine kinds/components while separately reading the static transform set linked into aether-mcp. Typing is the substrate of observability, not paperwork.

Kinds — typed payloads

A kind is a named, sendable mail payload — a Rust type carrying a name, a stable id, and a wire encoding. You declare one with two derives and a name:

#![allow(unused)]
fn main() {
#[derive(aether_data::Kind, aether_data::Schema)]
#[kind(name = "aether.audio.note_on")]
struct NoteOn { pitch: u8, velocity: u8, instrument_id: u8 }
}

Two derives, because they describe two different things.

  • Schema gives const SCHEMA: SchemaType — a description of the type's byte layout (ADR-0031) — and the owned wire codec that walks the same field list (ADR-0188). It's compositional: every type that can sit inside a payload implements it — the primitives, String, Vec<T>, Option<T>, arrays, and your own structs — so a struct's schema is assembled from its fields'. A type that only ever appears as a field of a kind derives Schema alone. Serde derives are not required to put a type on the wire.
  • Kind marks the type as a top-level, addressable payload: const NAME (the declared name), const ID: KindId, and the encode/decode bodies. Kind is layered on Schema — the id is hash(name + canonical(SCHEMA)), so the derive reads the type's schema to compute it. A kind is always a schema; a schema is not always a kind.

So you write #[derive(Kind, Schema)] on a message you send, and #[derive(Schema)] alone on a helper struct that only appears as a field of one. That gives a kind three compile-time constants — NAME and ID from Kind, SCHEMA from Schema. In practice Schema is derived on far more types than Kind: every field-only struct has one, but isn't independently sendable.

Why not just fold Schema into Kind, so one derive does everything? Because a kind's schema is composed from its fields' schemas by trait dispatch — the derive emits <Self as Schema>::SCHEMA, which recurses into <FieldType as Schema>::SCHEMA for each field. For that to resolve, every field type — including nested structs that are never mailed — has to implement Schema on its own, so Schema must be a standalone derive regardless. Given that, Kind reads the schema rather than recomputing it; merging the two would duplicate the schema walk that ADR-0031/ADR-0032 deliberately collapsed into one trait-dispatched path. (It's the Serialize / Deserialize split: one trait, one derive.)

Because ID and SCHEMA are const, there is no host round-trip to learn a kind's identity or shape — Kind::ID is a compile-time value. And because the schema travels with the type, the wire layer can encode a kind from JSON and a recipient can decode it without a shared header (ADR-0019). On the wire a #[repr(C)] plain-data kind rides as a raw byte cast; everything else as a structured encoding — the derive autodetects from the type's layout, so a single send / reply call site handles both.

What feeds the id — and what doesn't. The KindId hash takes name + schema, where name is the declared kind name and schema is the structural shape — field types and positions. All nominal information (the Rust type name, field names, variant names) is erased from the hashed bytes and carried in a parallel labels sidecar instead (ADR-0031/ADR-0032). So:

editid changes?why
add / remove a fieldyesshape changed
change a field's typeyesshape changed
reorder fieldsyespositions are structural
rename a field (same type)nofield names are erased
rename the Rust structnotype names are erased
change #[kind(name = "…")]yesthe name half of the hash

This is what makes schema drift fail loud rather than silently garbage-decode — a mismatched producer and consumer compute different ids and the mail lands on "kind not found." The full contract is in Invariants & guarantees.

For the end-to-end walkthrough of declaring a new substrate kind — the derives, the self-registering descriptor, the handler, and the rebuild rule when an edit moves the id — see the Adding a substrate kind recipe.

The schema vocabulary — SchemaType

A kind's shape is a tree of SchemaType. The leaves and containers are what you'd expect — Bool, Scalar (the primitives), String, Bytes, Blob, Option, Vec, Array (fixed-length [T; N]; Vec is the variable-length form), Struct, Enum, and Map (a keyed lookup table). The arms that carry rules worth knowing beyond "it's a type tree":

  • Two wire shapes, and what picks them. A struct encodes as a raw #[repr(C)] byte cast (repr_c = true) only if it is #[repr(C)] and every field is cast-eligible, recursively; otherwise it encodes as structured. Cast-eligible means a scalar primitive, a typed-id newtype other than MailboxId (KindId and friends are #[repr(transparent)] over u64), a fixed [T; N] array of cast-eligible elements, or a nested all-cast-eligible #[repr(C)] struct. A MailboxId field makes a struct structured, because a kind that names an actor carries an address rather than a cast position. A single String, Bytes, Vec, Option, Map, Enum, or Ref field anywhere short-circuits the whole struct to structured. You don't choose this — the derive computes it at compile time (CastEligible::ELIGIBLE ANDs every field) — but it's why two similar-looking kinds can have different wire encodings. The send path carries the same split: the Kind derive emits Kind::encode_into_bytes as a call to encode_cast (a bytemuck byte copy) for a #[repr(C)] kind and to encode_wire (the ADR-0118 wire body) otherwise, so a send site never picks the encoder itself.
  • Map keys are restricted. A map key may only be a String, an integer scalar, or Bool — the BTreeMap<K: Ord, V> bound rules out f32/f64/ Vec/Option at the type level and the codec rejects them defensively. Entries serialize in key-sorted order, and a map (being variable-length) always forces its parent struct onto the structured path.
  • The canonical bytes are positional-only. When a schema is serialized for hashing and for the aether.kinds manifest, field and variant names are dropped; a separate labels sidecar (aether.kinds.labels) carries them for consumers that want human-readable reconstruction, like describe_kinds (ADR-0032). The wire never carries names — structured fields are positional.
  • TypeId is a schema arm. A kind can have a field that is an id — a MailboxId or KindId as a first-class typed reference, not just a bare u64 (ADR-0065). These encode as a tagged string on JSON and a varint on the wire.
  • Blob is immutable bytes held as an aether_data::Blob (ADR-0238). Its binary form starts with a one-byte tag. Tag 0 is a u32 length and the bytes, and every codec that writes bytes writes it: files, the journal, the wire, JSON. Tag 1 is the blob's 32-byte hash, written only in in-process mail and never outside the process. The JSON codec and MCP read and write a Blob field as a plain byte array and never show the tag. A Blob is not cast-eligible, and a Blob field is a different kind from a Vec<u8> field.
  • Decoding with a context. Every kind decodes through one body, Kind::decode_with(bytes, &mut DecodeCtx), which returns the value or a named wire::Error (ADR-0231 §3). The DecodeCtx offers a field two operations: resolve a tag-1 Blob hash, and prove that the route at a path, live or closed, publishes a protocol's rows. A resolved Blob is always a reference to its store entry, never a copy; tag 0 needs no context and decodes owned. Native dispatch builds the context from the inbound mail's attachments and the mail registry; a guest's context carries its blob holds and asks the host for the registry's rows, one host call per ProtocolPath<P> field. A field refuses what its context cannot prove: a tag-1 Blob with no resolver refuses DetachedBlob, and a ProtocolPath<P> refuses ProtocolPathUnchecked with no registry, ProtocolPathUnpublished when no route has stood at its path or it is still starting, and UncoveredProtocolPath when that route does not publish every row of P. A path whose actor has closed decodes, and the receiver's resolve answers that it is not live. A refused decode reaches no handler and is logged at warn. A request whose typed path refused is still answered: a kind that carries a path sets Kind::PROVES_ROUTES, its reply implements From<PathRefused> (the compiler refuses a replying handler whose reply does not), and the dispatch answers O::from(refused). A silent or unchecked row has no reply, so nothing is sent back. The plain shorthand, decode_from_bytes, decodes with an empty context. Serde carries no context, so ProtocolPath<P> has no Deserialize, and a kind carrying one is declared #[aether_data::kind(.., no_serde)].

What counts as the same kind

The canonical schema is a positional encoding: each arm becomes a type tag plus its structural content — field and variant names are dropped, but field order, array lengths, enum discriminants, and the repr_c flag are all kept. Two kinds with the same declared name share a KindId exactly when those bytes match. The "which edits move the id" matrix above lists the basics; the cases that surprise people live at the schema-arm level. Holding the #[kind(name = …)] fixed:

Still the same kind — the id doesn't move:

  • renaming a field but keeping its type — { x: u32 } and { count: u32 } are identical;
  • renaming the Rust struct, or renaming an enum variant while keeping its discriminant and payload shape.

A different kind, despite looking alike:

  • Reordering fields — { a: u32, b: u8 } ≠ { b: u8, a: u32 }; positions are structural.
  • A same-size type swap — { a: u32 } ≠ { a: i32 }, and { a: u64 } ≠ { a: MailboxId } even though both are eight wire bytes: a typed-id field is a distinct TypeId arm, not a Scalar.
  • Flipping #[repr(C)] when it changes the cast/structured choice — repr_c is part of the canonical bytes, so the same fields under a different wire format are a different kind.
  • Wrapping a field — { a: u32 } and { a: Option<u32> } are distinct kinds; likewise [u32; 3] ≠ [u32; 4] ≠ Vec<u32>.
  • Changing an enum discriminant — discriminants are encoded; variant names are not.

Mailboxes — typed addresses

A mailbox is an address: where mail goes. Addressing rests on two ids, one per question (ADR-0099):

  • An ActorId answers which actor. It is a 64-bit hash of the actor's NAMESPACE alone — hash(NAMESPACE) for a singleton actor, hash(NAMESPACE:subname) for an instanced one — with no schema in the hash, because an actor's identity is its name (ADR-0029). It names the actor wherever it is hosted, and it reverse-maps to that name for introspection.
  • A MailboxId answers where in the tree. An actor sits somewhere — at the substrate root, or hosted under a parent — and its lineage is the ordered list of ActorIds from the root down to it. The MailboxId is a hash chain over that lineage (fold_lineage, one fold step per node), and mail routes to it.

For a root actor — every chassis capability — the lineage is a single node, and the fold of one node is that node: MailboxId == ActorId == hash(name), so the name hash of ADR-0029 is the depth-1 case of the fold. A hosted actor — a loaded component, a spawned child — folds its ActorId onto its parent's, so the same code under two different parents is two different mailboxes. The /-rendered addresses you see (aether.window/aether.window.instance:main) are a display rendering of the lineage, one segment per ActorId; a written path resolves by parsing it into segments and re-folding, which happens in the host registry, never by hashing the joined string.

Both ids are computed from compile-time constants with no registry lookup, so every process that holds the same names and the same lineage computes the same ids — and a component hot-swap, which changes neither, keeps the address valid. In a component you rarely touch either id directly: you address a peer by type (ctx.send::<RenderCapability>(&kind) under depends(RenderCapability)) or send through a proven reference it holds (ctx.send_to(reference, &kind)). The mailbox-vs-kind distinction — why an address and a payload shape are different things even when they share a name prefix — is the Mail, kinds & scheduling page's subject.

Transforms — typed pure functions

A transform is a pure function over typed values — #[transform] fn foo(input: A) -> B, registered at build time with a TransformId and typed by its input and output kinds (ADR-0048). Where a kind is a value and a mailbox is an address, a transform is a typed edge: it takes a value of one kind and produces a value of another. The aether.fs fetch verb runs a fold chain of them over a file's bytes; inspect the current MCP build's linked set with describe_transforms.

Typed ids — the naming layer

Every typed thing is named by a newtype over a hash, not a bare integer:

idwrapsderived from
KindIdu64name + schema (ADR-0030)
ActorIdu64the actor's NAMESPACE (plus :subname when instanced) (ADR-0099)
MailboxIdu64the actor's lineage — a hash chain of ActorIds, root → leaf; a root actor's equals its name hash (ADR-0029/ADR-0099)
TransformIdu64the transform's identity (ADR-0048)
EngineId, SessionTokenUuidwire identity of an engine / session

Two properties keep these from being foot-guns:

  • Disjoint hash domains. A kind id and a mailbox id are hashed with different domain prefixes, so the same name produces different ids in the two spaces — the id spaces don't collide even when names are shared (ADR-0030). This is the mechanical reason a kind name and a mailbox name can look alike yet address different things. (ActorId deliberately shares the mailbox domain — a root actor's MailboxId is its ActorId, which is what keeps every chassis capability's id equal to its name hash.)
  • Tagged strings on the MCP wire. Across the agent boundary, ids encode as <tag>-XXXX-XXXX-XXXX — mbx-… for a mailbox, knd-… for a kind (ADR-0064). The tag makes an id self-identifying, so a mailbox id and a kind id can't be silently swapped in a tool call. Hand these back verbatim — they're opaque tokens, not numbers to parse.

How it composes

The pieces stack: a kind describes some bytes; a schema describes the kind; a mailbox is where a kind is sent; a transform turns one kind into another; and a typed id names each so the owning inventory can report what exists. Because every one of them is self-describing, the system is inspectable from the outside — with engine-scoped and MCP-build-static views kept distinct.

Where to read more