Component registry

Aether has two component inventories with different owners. The hub knows which wasm artifacts are stored. A substrate knows which component actors are live. Treating one as proof of the other causes most component-operation mistakes.

Three views, three meanings

ViewOwnerCurrent public accessWhat a row proves
stored artifact registryhubupload_component, registry list_componentsbytes and manifest are available to selectors
trampoline/capability registryone substrateload_component, describe_component; live queries behind thema lineage mailbox and its retained capabilities at observation time
MCP capability/kind cachesaether-mcp processpopulated and refreshed by tool callsan optimization, never the authority for liveness

Registry list_components is deliberately artifact-facing. It does not list loaded instances. The same artifact can be loaded zero times, once, many times on one engine, or into several engines.

Current component tools

ToolPurpose
upload_componentingest a .wasm path into the hub store; optional pin: true
list_componentslist/filter stored component artifacts
pin_artifactdurable explicit pin by exact stored content hash
unpin_artifactdrop only the explicit pin; a name still protects
publishbind a stored module's namespaces in one engine; a successor republishes every live instance of them
spawnstand up (or find live) an instance of a published type
load_componentpublish then spawn in one call
describe_componentinspect a live instance's or a published type's receive surface
describe_kindsinspect its config, input, and reply schemas in the engine's live vocabulary
send_maildrive a component, including the generic aether.component.drop lifecycle mail

There is no dedicated drop_component MCP tool. Dropping is nevertheless a current engine operation: use send_mail to the aether.component mailbox with kind aether.component.drop and target set to the exact address returned by the load. Inspect that kind with describe_kinds before constructing params. Await its aether.component.drop_result; do not fire-and-forget cleanup.

Drop runs the guest's unwire, releases the wasm, and closes the instance (ADR-0241 §8). The close purges capability-owned subscriptions and routes through each watcher's MonitorNotice, and the name tombstones for the engine's lifetime: a later load of it is refused as retired, and a second drop at the path is refused. A drop that arrives while the instance's module republishes waits until the republish answers. Load or spawn under a new name to bring the component back.

Upload before selector

Only upload_component accepts a filesystem path. publish, load_component, and boot component specs accept registry selectors—not paths and not inline wasm bytes.

The reliable sequence is:

  1. Build the wasm artifact on the same fleet host whose path the hub can read.
  2. Call upload_component(staged_path, name?, pin?). pin: true records durable explicit protection before this upload's eviction, including unnamed uploads. pin: false (the default) never clears an existing pin.
  3. Record the returned content hash and optional name.
  4. Confirm its manifest with registry list_components; when no name was supplied, set include_history: true and locate the returned hash.
  5. Use the hash or name as the later selector.

Upload reads the wasm manifest without executing the module. It records exported actor namespaces, handled kind ids, fallback presence, and provenance. Identical bytes deduplicate to one hash.

Which selector?

SelectorMeaningUse when
content hashexact stored bytesrollout, rollback, reproducible test
namehash currently pointed to by that nameoperator-managed “latest” workflow
module@actorstored module plus exported actor typemulti-actor module selection

An explicit export field overrides the actor half of module@actor. A module exporting several types must be given an export; an omitted export resolves only against a module with exactly one selectable (non-boot) export, and is otherwise a clean load error naming the exports, not “first actor wins.”

Names are mutable. Re-uploading under the same name repoints it. The old hash remains stored and, once no other name points at it, becomes unnamed history eligible for LRU eviction unless it still carries an explicit pin. It is not deleted by repointing. Hashes are therefore the correct deployment evidence. unpin_artifact removes only the explicit flag; it is not unname or delete.

Stored registry behavior

list_components can filter stored manifests by exported namespace and handled_kind; filters are AND-combined. Its normal result is a bounded, newest-first page of name-pointed entries. include_history: true admits unnamed hashes. Use the returned total_matched and an explicit limit when a complete history is actually needed.

A named artifact is protected from disk-budget LRU eviction. An unnamed, unpinned history entry is eligible. pin_artifact / unpin_artifact (and upload_component(pin: true)) record or drop durable explicit protection on an exact content hash — names are never resolved. pin: false on upload is not unpin. There is still no delete or unname operation. Component runtime protection of stored artifacts is not provided here: the hub holds the binary of every engine it supervises (issue 5686), but it does not supervise a loaded component, so a component's stored wasm is protected by a name or a pin alone. Fleet and MCP must ship the same release: the pin field changes the typed upload kind schema.

Loading into an engine

load_component resolves the selector at the hub, publishes the bytes to the target engine's aether.component cap, then spawns the selected type and waits for its SpawnResult.

On a single load, record both outputs:

  • address: the full lineage address used as send_mail.address, the aether.component.drop kind's target, a publish config's address, and by live describe_component.
  • capabilities: handled kinds, reply contracts, fallback, docs, and config kind for the selected actor type.

Never reconstruct the lineage from a short load name. Use the returned value, the component's published name: NS for a singleton, NS:key for an instanced type.

Configuration

Pass either inline JSON in config or a JSON-file path in config_path, never both. The MCP layer resolves the selected artifact's declared Config kind and schema-encodes that JSON before loading. Providing config to a component that declares no Config kind is an error. Use describe_component to learn the kind name and describe_kinds for its exact live schema.

Replicas

replicas: N (1..=256; replicas: 0 is refused, and a key combined with replicas is refused, since each replica draws its own counter key) publishes the module once, then spawns N counter-keyed instances of the selected type (NS:0, NS:1, …). An entry that names no replicas spawns the one instance its key names, or a counter key when it names none. The result carries one shared capabilities block, since every instance is the same type, and an instances list of each spawned address and whether it was already live.

A replica fan-out is not transactional. If instance K fails, the instances before it remain live and the error says how many spawned. The failed call does not return the successful prefix's instances records. aether.component.list reports which are live. On a task-owned engine, terminate and start clean. On a shared engine, stop and report the partial prefix rather than guessing addresses or retrying into occupied ones.

Boot-time replicas use the same counter-keyed expansion. The substrate binds only after every boot instance has answered its spawn, so a successful spawn_substrate means every instance is live. A boot spawn that fails fails the boot with a spawn_failed entry rather than reporting the engine ready.

Live introspection

describe_component should normally receive the lineage address. On a cache miss, aether-mcp forwards that name to the substrate, whose registry owns the live answer. This works for boot-loaded components, after an aether-mcp restart, and after a successful in-place replacement.

The process-local capability cache is consulted before that live query. Loads and replacements refresh it, but a generic aether.component.drop sent through send_mail does not invalidate it. In that case, a same-process describe can return pre-drop capabilities. The successful drop_result is the unload proof; only a later name lookup after a cache reset is guaranteed to miss the cleared substrate capability registry.

The cache is keyed by engine and canonical path, with no hub-epoch component. After a hub restart reuses an engine_id, a boot-loaded component at the same lineage can collide with an earlier capability entry. Use live probes or reset/reconnect aether-mcp when an exact clean-epoch description is required.

An address is a lineage or a short path. Keep the lineage address as the durable observation handle for the life of the engine.

The live kind vocabulary is also substrate-owned. Loading registers the wasm's kind descriptors into the same registry served by aether.inventory. describe_kinds(engine_id: …) reads that view. The mail encoder refreshes its per-engine cache once on an unknown kind or a stale-schema encode failure, but the cache does not make an unloaded kind live. Kind descriptors are not removed when a component drops, so kind presence is vocabulary evidence, not component liveness evidence.

The standalone description call currently suppresses a failed live refresh and can return its static/prior snapshot. Use a bounded live probe when freshness or engine reachability matters.

Replacing safely

A publish of a successor republishes a module (ADR-0241 §7). Pass the current engine id and a previously uploaded selector; prefer a content hash so the successor is unambiguous. A publish names no instance and takes no module@actor selector: every live instance of every namespace the module publishes moves to the successor together, or none does, and each keeps its address and mailbox.

Identical bytes answer Ok with no swap. Before any instance is touched the host refuses the whole republish, naming each instance a check refuses, when the module is content-addressed, already has a republish in flight, declares a boot or succeeds one that did, drops a namespace or narrows a handler row or fallback of one, adds a dependency that is not live to a live instance's type, or leaves an instance without a config of its type's kind. An instance whose type's config kind changed needs a configs entry, {address, config | config_path}, which the harness encodes to the successor's Config kind; every other instance keeps its stored config. An inline child cannot be given a config, so a config-kind change on a live inline child's type is refused.

Then every instance prepares its successor while mail for it waits at its inbox gate, the module publishes, and every instance commits, unwiring the old guest first and receiving the waiting mail in order. The reply, {engine_id, types: [{namespace, capabilities}]}, comes once every commit's chain has settled. A refusal while preparing, or a publish failure, aborts every instance: each unwires its candidate exactly when that candidate wired, reinstates its old guest, still wired, which gets back the state its on_dehydrate saved through its on_rehydrate, with no second wire. Only teardown outside that saved state and outside what wire built stays gone. Nothing a failed successor sent leaves. Loads and drops of the module's namespaces that arrive meanwhile wait for the answer.

Require an explicit successful result, then re-run describe_component and a safe probe. After an error MCP's cache and the capability registry describe the old handler sets, which a reinstated guest may no longer fully serve if its hooks tore down state its saved state does not carry. Use Replacement failure states rather than treating describe_component as rollback proof.

Component cleanup

For a task-owned instance in a shared engine:

  • Preserve its logs and any trace evidence.
  • Send settled aether.component.drop through send_mail.
  • Require aether.component.drop_result success.
  • Do not use a same-process describe_component cache hit or lingering kind descriptor as unload confirmation.
  • Record that the name is retired, not freed: a reload needs a new name.

For an engine wholly owned by the task, terminating the engine is the simpler complete cleanup: all its live registries and components die with it. This does not delete the hub's stored component artifacts.

Source routes

  • Tool contracts and shapes: crates/aether-mcp/src/tools/mod.rs and args.rs
  • Upload/list/publish/spawn/load orchestration: crates/aether-mcp/src/tools/components/
  • Selector resolution, boot staging, and live kind caching: crates/aether-mcp/src/tools/state.rs
  • Hub artifact store: crates/aether-fleet/src/store/
  • Component host publish/spawn/load/list/describe/drop: crates/aether-component/src/component/
  • Drop-closes-the-instance behavior: crates/aether-component/src/trampoline/runtime/mod.rs
  • Trampoline republish behavior: crates/aether-component/src/trampoline/runtime/republish.rs
  • Live kinds: crates/aether-inventory/src/

For failure branches, continue with Recovery.