File I/O
Governing ADR: ADR-0041 (substrate file I/O and namespaces). The namespace and adapter contract is stable; copy and transform-folded fetch extend the original four operations. One backend ships today (a local-file adapter); the trait is built to take more.
An actor never opens a file. Everything it does crosses the mail boundary, and
the filesystem is no exception: the substrate owns the disk, and an actor reaches
it by mailing a request to the aether.fs mailbox and handling the reply. Read,
write, copy, delete, list, and transform-folded fetch cross the substrate;
never through a raw file handle the actor holds. That mail boundary is not, by
itself, an authorization boundary: copy is a raw-host-path exception described
below.
When you drive the engine over MCP, file I/O is how you stage what a run will
load — write a mesh DSL or a config blob into a namespace, then point a component
at the same path — and how you read back what a run produced. When you author a
capability or component, it's a request/reply exchange like any other sink: mail
aether.fs.read, handle the ReadResult that comes back.
Why it exists
Handing a component std::fs would defeat the split the whole engine is built
on. The substrate owns I/O so that a loaded actor's reach is exactly the
mailboxes it can address — give it arbitrary filesystem access and that boundary
is gone. Portability pushes the same way: a wasm guest has no filesystem
primitives at all, so disk access has to be a request the substrate services on
the guest's behalf. Routing it through mail keeps file I/O identical in shape to
the render, audio, and other sinks — one mental model, one boundary, capabilities
isolated behind the mail wall. The current copy handler weakens that model:
any caller that can address aether.fs can ask it to read a raw host path, with
no sender authorization check.
For ordinary namespace operations, addressing by logical namespace rather
than real path is what makes the boundary useful. An actor mails save /
slot1.bin, not
/Users/you/Library/.../slot1.bin; the substrate resolves the namespace to a
configured root and the actor never learns where the bytes actually live. So the
same component runs unchanged whether save points at a home directory on a dev
box or a tmpdir in CI, and a future backend can move the bytes somewhere else
entirely without the caller noticing. The source side of copy does not use this
addressing rule.
What it does
One mailbox, seven request surfaces. Everything addresses the aether.fs mailbox.
Each request kind pairs with a reply kind that names the same operation:
| Request | Fields | Reply | Ok adds |
|---|---|---|---|
aether.fs.read | addr | aether.fs.read_result | bytes (a blob) |
aether.fs.write | addr, bytes (a blob) | aether.fs.write_result | — (ack) |
aether.fs.delete | addr | aether.fs.delete_result | — (ack) |
aether.fs.list | addr | aether.fs.list_result | entries |
aether.fs.copy | from, to | aether.fs.copy_result | — (ack) |
aether.fs.fetch | addr, transforms | aether.fs.fetch_result | output_kind, data (a blob) |
aether.fs.load | addr | aether.fs.loaded (sent late, outside the caller's chain) | bytes (a blob) |
One addressing type. addr is a NamespaceAddr { namespace, path } — the
same shape everywhere, including copy's destination to. list addresses a
prefix through it (addr.path is matched as a prefix; empty lists the namespace
root), which is what the verb does with the address rather than a different way
of writing one, so it takes no special case.
Each reply is an Ok / Err enum echoing the request's address on both arms —
addr for the five namespace verbs, from + to for copy. The column above
is what Ok adds beyond that echo. An Err arm replaces the operation's added
data with its structured error. Each reply enum owns a from_op constructor
that folds an adapter Result into the two arms, so the echo is written once
per verb rather than at every return.
copy is trusted ingestion by convention, not enforcement. Its from
field is passed directly to std::fs::read, while to is a namespace address.
An absolute host path is the intended input, but the handler does not enforce
absoluteness: a relative value resolves against the substrate process's working
directory. More importantly, it does not authenticate the sender: any actor
that declares depends(FsCapability) can send copy and choose the raw source
path. Use it only where all such callers are trusted, or add a
real authorization/composition boundary before treating it as an operator-only
seam. The destination still applies its namespace's write policy and the lexical
path checks described below.
fetch composes registered transforms. It reads through a namespace, then
validates and runs an ordered linear TransformId chain. An empty chain returns
raw bytes. Failures distinguish file I/O, invalid composition, transform error,
and panic. Discover transform ids/contracts with describe_transforms; do not
guess ids or treat the facility as arbitrary host execution.
Four namespaces. A request names one by its short name — save, not
save:// (the double-colon form is only a convention in prose):
save— writable, per-user persistent storage. Save games, preferences.assets— read-only, the content a build ships with. Textures, meshes, level data. A write or delete here repliesForbidden.config— writable, for component-authored configuration (keybinds and the like).objects— read-only, the objects an engine reads by path after boot: the named objects a package ships (ADR-0163 §1), or the files of a plain directory on an engine with no package. Its verbs arereadandlist.
Each resolves to a real directory chosen at boot; that resolution, and the
AETHER_*_DIR knobs behind it, are covered under Configuration.
objects is how an actor loads a module boot did not. Read the object and
the reply's bytes is a Blob; put that blob in aether.component.publish as
code, then aether.component.spawn a type it published. A guest holds the
blob by handle and its publish names it by hash, so the object's bytes never
enter guest memory.
An object's path is stricter than a path in the other namespaces. It is one or
more segments joined by single /, with no leading or trailing / and no
empty segment; a segment is never . or .., and every byte of one is a-z,
0-9, ., _ or -. Lowercase only, because a case-insensitive filesystem
would otherwise give one object several names. A segment is at most 255 bytes
and a path at most 1024. modules/50_50/square.wasm is a path; Modules/a,
a//b, /a, a/../b and a b are not. A path that breaks the rule replies
Forbidden, as does every write and delete; a path that names no object
replies NotFound. list is one level deep and answers sorted bare names, as
in every other namespace: its prefix is empty for the root or a path naming a
directory, a malformed prefix replies Forbidden, and a prefix nothing sits
under replies NotFound.
The namespace finds an object's file in one of two ways, chosen at boot, and the same request works against both:
- A packaged chassis reads the package's table of named objects. A path is
a key of that table, the file read is the one under
pack/objectsnamed for the row's sha256, and a request's path is never joined to a directory. The table is checked at boot: a named object that is absent, or not the size the manifest records, fails the boot. An object boot loads is not in the table, so it cannot be read here. - Any other engine reads
AETHER_OBJECTS_DIR/--objects-dir, a plain directory where an object is the file at its path, defaulting toobjectsbeside the binary. The directory is never created, so an engine without one answersNotFoundfor every object and lists as empty.
So a directory of built files stands in for a package during development. The read is not verified in either case: integrity is the platform's job, and the engine's identity for the bytes is the one its blob store takes when they are checked in. Package depot covers how a package names its objects.
Local paths receive a lexical check, not complete containment. The local
adapter behind save, assets and config rejects a string that starts with / or contains a slash-delimited
segment exactly equal to .., so save / ../etc/passwd fails with
Forbidden. It then joins the unchecked remainder to the namespace root. It
does not canonicalize the resolved target or defend against symlinks inside the
root, and on Windows it does not recognize backslash traversal, drive-prefixed
absolute paths, or UNC paths. Namespace roots must therefore be trusted and
free of escape symlinks, and callers should use portable forward-slash relative
paths. Do not describe the current adapter as proving root containment across
all host filesystem states or platforms.
Writes are atomic, and fill in their parents. The local-file backend stages a write to a sibling temporary file and renames it into place, so a crash mid-write leaves either the old contents or the new — never a torn file. Missing parent directories along the path are created. There's no cross-actor locking, so two writers racing the same path resolve last-write-wins.
list enumerates a directory, shallowly. addr.path is resolved as a
directory path under the namespace root — an empty path lists the root — and the
reply's entries are the bare names directly inside it, sorted, with no
recursion. A missing directory replies NotFound. The names come back bare, so
you rebuild a path by joining an entry back under the path you listed.
Replies carry domain echoes and mail correlation. A handler dispatches on
the reply kind, which on its own erases which request a given reply answers.
Namespace operations echo addr, while copy echoes from and to,
preserving readable domain context. Per
ADR-0139,
those fields are informational rather than a duplicate-safe demultiplexing key.
Bind a typed context by sending with
ctx.send_with_context::<FsCapability>(&request, &context) and recover it once
with ctx.take_context::<Context>() in the reply handler. For write and
delete, whose Ok arms add nothing else, the echo still describes what landed
or was removed. The one deliberate omission is the write bytes — a write reply
doesn't echo them back, so persisting a megabyte still produces a small reply.
FsError is one of four shapes. NotFound (no such file or directory);
Forbidden (a read-only namespace, or a path rejected by the adapter's lexical
leading-//slash-.. checks);
UnknownNamespace (nothing registered under that name); or AdapterError(String)
(a backend failure — disk full, a rename that lost a race — with the detail
preserved as text). The first three are precise enough to branch on; the fourth
carries free-form context without locking the enum to one backend.
Frame-bound read, background load. The kind, not the send verb, says
whether the caller waits for the disk. A read holds the caller's chain until
its ReadResult goes out, so a read sent from a Tick handler holds that
frame: lifecycle advance waits on settlement. Reads run on a bounded queue of
worker threads, so several are in progress at once and a frame that sent
several waits for the slowest, not their sum; past the bound a read waits its
turn in arrival order. A load is an ordinary request too, sent with an
ordinary send, but aether.fs answers it late without holding the caller's
chain: the chain settles as soon as the request is handled, and the
aether.fs.loaded reply arrives afterwards, outside it, with any context the
caller bound. Loads run on their own bounded queue, so background loads never
hold up a frame's reads, and a load past the bound waits rather than being
dropped. write, copy, delete, list, and fetch still run on the
aether.fs turn itself.
The cap is wired on the desktop and headless chassis. On a chassis that doesn't
run it, aether.fs isn't a registered mailbox, so mail to it warn-drops like any
unaddressed name.
How to use it
From a component. Declare depends(FsCapability) and send the request kind:
#![allow(unused)] fn main() { ctx.send::<FsCapability>(&Write { addr: NamespaceAddr::new("save", "slot1.bin"), bytes: bytes.into() }); ctx.send::<FsCapability>(&Read { addr: NamespaceAddr::new("save", "slot1.bin") }); }
The send returns at once; the result arrives later as its own mail, which you
receive like any other kind. A read still holds the chain it was sent on until
that reply goes out:
#![allow(unused)] fn main() { #[handler::response] fn on_read_result(&mut self, ctx: &mut WasmCtx<'_>, result: ReadResult) { match result { ReadResult::Ok { addr, bytes } => { // A native consumer reads `bytes.contiguous()`; a guest streams // the blob with `BlobReader`. } ReadResult::Err { addr, error } => { /* addr supplies readable domain context */ } } } }
The bytes in ReadResult, FsFetchResult, and Write are an aether_data::Blob.
An in-process read shares the entry the cap checked into the engine blob store
instead of copying it, while MCP and wire callers still see plain bytes (the
$text example below stays valid).
A ctx that omits its actor is typed by it: the macro reads WasmCtx<'_> as
WasmCtx<'_, Self>, so the ctx reaches only the actors the component declares
with depends(R). The ctx's type arguments are receiver, sender, mode: the actor first, the
sender the handler requires second (Anyone when it states none), and the
reply mode third (WasmCtx<'_, Self, Anyone, Unchecked>); spell
WasmCtx<'_, Erased> for the untyped view.
The echoed addr makes logs and MCP replies readable, but it does not uniquely
identify duplicate concurrent reads. For duplicate-safe
one-shot matching, derive Kind for a small context, send it with the request,
and take it from the matching reply:
#![allow(unused)] fn main() { let context = FontLoadContext { source: ctx.reply_target() }; let addr = NamespaceAddr::new(namespace, path); let request_id = ctx.send_with_context::<FsCapability>(&Read { addr }, &context); // In the ReadResult handler: let Some(context) = ctx.take_context::<FontLoadContext>() else { return; }; }
send_with_context returns the minted RequestId, so a caller that also
needs the id keeps it; one that does not can drop it. (The request and reply
kinds live in aether-fs/src/kinds.rs.)
To read without holding the frame, send load instead and handle its reply as
you would any other. Bind a context to tell concurrent loads apart; it comes
back with the late reply:
#![allow(unused)] fn main() { ctx.send_with_context::<FsCapability>(&Load { addr: NamespaceAddr::new("assets", "level1.bin") }, &AssetSlot { index }); #[handler::response] fn on_loaded(&mut self, ctx: &mut WasmCtx<'_>, loaded: Loaded, slot: AssetSlot) { match loaded { Loaded::Ok { addr, bytes } => { /* arrives after the sending chain settled */ } Loaded::Err { addr, error } => { /* the same FsError a read gives */ } } } }
The reply joins no chain, so mail its handler sends starts fresh chains of its own.
From an agent over MCP. send_mail rides settlement and hands back the
correlated reply, so a read is a single call: mail aether.fs.read to aether.fs
and the ReadResult bytes come back with it — no polling. Use read here, not
load: a load's chain settles before its reply exists. describe_kinds
carries the exact param schema for each request if you need it.
The address is a nested object, not two top-level fields:
// send_mail → aether.fs (kind: aether.fs.read)
{ "addr": { "namespace": "save", "path": "slot1.bin" } }
// send_mail → aether.fs (kind: aether.fs.write)
{ "addr": { "namespace": "save", "path": "slot1.bin" }, "bytes": { "$text": "hello" } }
// send_mail → aether.fs (kind: aether.fs.list) — addr.path is the prefix
{ "addr": { "namespace": "save", "path": "" } }
The move you'll reach for most is write: stage a file the engine will then
load. Writing a mesh DSL to a namespace and pointing the mesh viewer at the same
path is the canonical loop — author the bytes, then send the load — and the same
shape covers dropping a config blob a component reads at startup. The namespaces
resolve to real directories you set per-spawn through AETHER_SAVE_DIR /
AETHER_ASSETS_DIR / AETHER_CONFIG_DIR / AETHER_OBJECTS_DIR (or the matching --save-dir flags), so
you also control where those bytes land on disk.
How to extend or reuse it
The seams are the namespace table and the backend trait.
- A new namespace is registered at boot, chassis-side: build an adapter,
register it under a short name, and it's addressable as just another
namespace. The four above are the substrate's defaults; chassis code can add more. - A new backend is an implementation of
FileAdapter— four methods (read/write/delete/list), each returningFsResult. The trait is deliberately small so a bundled-asset or cloud backend is "implement four methods," not a rewrite of the sink. The local-file adapter is the one that ships; it is the reference for the current semantics, including the lexical path-check gaps documented above. A replacement must state or enforce its own containment and atomicity contract rather than assuming stronger behavior.
The mail surface doesn't change when a backend does. A component addressing
save / slot1.bin is untouched whether those bytes live on local disk or
somewhere a future adapter puts them — which is the entire point of resolving by
namespace rather than path.
Where to read more
- The transport, the namespace-to-adapter design, and the precedence of config layers — ADR-0041.
- Where the namespace roots come from and how to set them — Configuration.
- Why a single
send_mailreturns the read's bytes — the settlement contract on Tracing & settlement, and the tool surface on The MCP harness. - How a component receives a reply kind in a
#[handler::<class>]— Components & lifecycle.