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:

  1. a compatibility decision and ADR when load-bearing;
  2. encode/decode round trips and malformed/oversize tests;
  3. hub, proxy, child, and MCP caller updates;
  4. mixed-version behavior stated explicitly;
  5. 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