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.
Schemagivesconst 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 derivesSchemaalone. Serde derives are not required to put a type on the wire.Kindmarks the type as a top-level, addressable payload:const NAME(the declared name),const ID: KindId, and the encode/decode bodies.Kindis layered onSchema— the id ishash(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:
| edit | id changes? | why |
|---|---|---|
| add / remove a field | yes | shape changed |
| change a field's type | yes | shape changed |
| reorder fields | yes | positions are structural |
| rename a field (same type) | no | field names are erased |
rename the Rust struct | no | type names are erased |
change #[kind(name = "…")] | yes | the 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 thanMailboxId(KindIdand friends are#[repr(transparent)]overu64), a fixed[T; N]array of cast-eligible elements, or a nested all-cast-eligible#[repr(C)]struct. AMailboxIdfield makes a struct structured, because a kind that names an actor carries an address rather than a cast position. A singleString,Bytes,Vec,Option,Map,Enum, orReffield anywhere short-circuits the whole struct to structured. You don't choose this — the derive computes it at compile time (CastEligible::ELIGIBLEANDs every field) — but it's why two similar-looking kinds can have different wire encodings. The send path carries the same split: theKindderive emitsKind::encode_into_bytesas a call toencode_cast(abytemuckbyte copy) for a#[repr(C)]kind and toencode_wire(the ADR-0118 wire body) otherwise, so a send site never picks the encoder itself. Mapkeys are restricted. A map key may only be aString, an integer scalar, orBool— theBTreeMap<K: Ord, V>bound rules outf32/f64/Vec/Optionat 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.kindsmanifest, field and variant names are dropped; a separate labels sidecar (aether.kinds.labels) carries them for consumers that want human-readable reconstruction, likedescribe_kinds(ADR-0032). The wire never carries names — structured fields are positional. TypeIdis a schema arm. A kind can have a field that is an id — aMailboxIdorKindIdas a first-class typed reference, not just a bareu64(ADR-0065). These encode as a tagged string on JSON and a varint on the wire.Blobis immutable bytes held as anaether_data::Blob(ADR-0238). Its binary form starts with a one-byte tag. Tag 0 is au32length 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 aBlobfield as a plain byte array and never show the tag. ABlobis not cast-eligible, and aBlobfield is a different kind from aVec<u8>field.- Decoding with a context. Every kind decodes through one body,
Kind::decode_with(bytes, &mut DecodeCtx), which returns the value or a namedwire::Error(ADR-0231 §3). TheDecodeCtxoffers a field two operations: resolve a tag-1Blobhash, and prove that the route at a path, live or closed, publishes a protocol's rows. A resolvedBlobis 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 perProtocolPath<P>field. A field refuses what its context cannot prove: a tag-1Blobwith no resolver refusesDetachedBlob, and aProtocolPath<P>refusesProtocolPathUncheckedwith no registry,ProtocolPathUnpublishedwhen no route has stood at its path or it is still starting, andUncoveredProtocolPathwhen that route does not publish every row ofP. A path whose actor has closed decodes, and the receiver'sresolveanswers 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 setsKind::PROVES_ROUTES, its reply implementsFrom<PathRefused>(the compiler refuses a replying handler whose reply does not), and the dispatch answersO::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, soProtocolPath<P>has noDeserialize, 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 distinctTypeIdarm, not aScalar. - Flipping
#[repr(C)]when it changes the cast/structured choice —repr_cis 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
ActorIdanswers which actor. It is a 64-bit hash of the actor'sNAMESPACEalone —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
MailboxIdanswers 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. TheMailboxIdis 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:
| id | wraps | derived from |
|---|---|---|
KindId | u64 | name + schema (ADR-0030) |
ActorId | u64 | the actor's NAMESPACE (plus :subname when instanced) (ADR-0099) |
MailboxId | u64 | the actor's lineage — a hash chain of ActorIds, root → leaf; a root actor's equals its name hash (ADR-0029/ADR-0099) |
TransformId | u64 | the transform's identity (ADR-0048) |
EngineId, SessionToken | Uuid | wire 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. (
ActorIddeliberately shares the mailbox domain — a root actor'sMailboxIdis itsActorId, 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
- The contracts these types enforce — Invariants & guarantees.
- Addresses vs payloads, in depth — Mail, kinds & scheduling.