RPC wire and engine routing
Aether RPC carries engine control and mail between processes. It is an internal typed transport used by the hub, child substrates, and MCP coordinator—not a general application replacement for HTTP or TCP actors.
Connection contract
Peers exchange a framed WireFrame vocabulary. Normal clients begin with
Hello/HelloAck, which exchange the wire version and a PeerKind containing
a substrate or client name/version (plus a substrate's shallow kind list).
PeerKind does not carry an EngineId or parent/child relationship. More
importantly, the current server records hello_received but does not consult it
before dispatching Call: a peer can send an ordinary call before Hello and
the server accepts it. Treat the handshake as the current client convention and
version exchange, not an admission or authentication gate. Network access to
the RPC listener is therefore its own trust boundary. Frames cover call/result
routing, mail envelopes, replies, heartbeats, and protocol errors.
The body encoding is Aether's canonical wire format. ADR-0118 amends older ADRs and comments that refer to postcard. Length-prefix and maximum-frame checks remain part of the stream boundary.
Addressing
A Call names its recipient with a Recipient: an optional engine selection
plus an ErasedActorPath (ADR-0166, canonical or short), beside the kind and the
encoded bytes. engine = None means the current RPC server's local actor
registry, not specifically the engine-control capability; a hub fleet
operation uses that form with the aether.fleet path. Per-engine operations
use Some(id) and route through the hub's matching FleetProxy, which sends
the path on as written.
engine = none → the path, resolved in this RPC server's local registry
engine = some(id) → the proxy registered for id → the path, resolved in the child registry
Only the engine that hosts the recipient resolves the path, on arrival
(ADR-0230 §3), so only it expands a short path; no client, harness, or hub
computes a mailbox id. A path that does not resolve there to a Live actor,
whatever the reason (never registered, still starting, dropped, or an
ambiguous or illegal short path), closes the call at once with
RpcError::NotPresent { path, detail }, where detail is the engine's
diagnostic. The hub relays that refusal to its caller unchanged. A reply
(ReplyEvent) carries its kind and bytes and no address.
A native recipient that refuses the payload at decode (a raw client, schema
drift between versions, or an encoding against a stale schema) runs no handler
for it. A request whose typed path did not prove is answered by the recipient
itself: its reply's Err names the path and why (ADR-0231 §3), and that reply
is the call's one answer. Any other refusal sends no reply, and the call still
closes naming it: once its chain settles it ends with
RpcError::DecodeRefused { path, kind, error }, where path is the refusing
actor and error the decode error, and the hub relays it unchanged. So
DecodeRefused closes only a call its recipient does not answer. A guest
recipient's unanswered refusal still ends Ok with no reply.
Each FleetProxy registers its engine with the hub's RPC server once it is
live (aether.rpc.register_engine_route), and the server forwards every
Some(id) call straight to that proxy. The server monitors each registrant:
when a proxy departs, its route is retired and any call still in flight at it
closes with an error. A call for an engine that never registered, or whose
proxy has departed, closes at once with RpcError::UnknownEngine. A spawn
settles only after the new engine's route exists, so an engine id a spawn hands
back is already routable.
Using Some(id) prevents a child mailbox from accidentally resolving in the
hub registry. It also makes an unknown/dead engine distinguishable from an
unknown actor inside a live engine.
Heartbeats and failure
The proxy/server relationship uses ping/pong heartbeats and connection state. Missed heartbeats can evict an engine; clean termination, crash, eviction, and spawn failure are retained as different recently-dead reasons.
RPC failure is not proof that the child never performed an operation. If a connection fails after dispatch, re-read fleet/component state before retrying a consequential call such as spawn, upload, or replace.
Schema and reply projection
RPC transports bytes and descriptors; aether-mcp adds the agent-facing JSON
layer:
- named kinds are schema-encoded against a per-engine cache;
- reverse names come from engine inventory;
- reply events and settlement are projected into bounded JSON;
- oversized byte leaves/responses spill to scratch files with summaries.
Do not add engine semantics only to the projection layer. A new operator tool should rest on a real capability/RPC/mail contract that other clients and tests can exercise.
When to change RPC
Change the wire only for a cross-process responsibility. Ordinary new actor
kinds travel inside MailEnvelope and do not require a new WireFrame variant.
RpcError::UnknownEngine was appended after every existing variant without a
WIRE_VERSION bump: appending keeps every earlier variant's tag, only the hub's
RPC server produces it, and aether-mcp ships from the same build.
WIRE_VERSION 2 (issue 6570) names a Call's recipient by ErasedActorPath in
place of a mailbox id, refuses an unresolved path with RpcError::NotPresent,
and drops the address from replies. Mixed versions do not interoperate: a peer built before it is
refused at the handshake, and a stored pre-change substrate binary fails its
spawn with a handshake error, so re-upload the substrate binary after
upgrading the hub.
Wire changes need:
- a compatibility decision and ADR when load-bearing;
- encode/decode round trips and malformed/oversize tests;
- hub, proxy, child, and MCP caller updates;
- mixed-version behavior stated explicitly;
- bounded allocation before trusting peer-provided lengths.
Change route
- Frames and addresses:
crates/aether-rpc/src/wire.rs - Native server/session:
crates/aether-rpc/src/server/ - Engine proxy/server:
crates/aether-fleet/src/ - MCP client/session:
crates/aether-mcp/src/{rpc.rs,tools/} - Stream framing:
crates/aether-codec/src/frame.rs - Decisions: ADR-0072 (amended), ADR-0074, ADR-0089, ADR-0118