# The VFS wire protocol

> **Status:** built and spoken today between `file_system` (the client) and the
> filesystem BACKENDS (the FAT server). The mount router lives in the
> **kernel** (`system/kernel/vfs.zig`): `fs_resolve` routes a path and either
> serves it directly (the read-only /system initrd mount, via `fs_node`) or
> redirects the caller to the owning backend's endpoint plus the rewritten
> mount-relative path — after which the client speaks THIS protocol to the
> backend, unchanged. Since P4a the contract is expressed through
> `envelope.Define` (docs/os-development/protocol-namespace.md), so every
> packet begins with the universal 16-byte prefix and the open-node id rides
> in it. The Zig source of truth is `library/protocol/vfs/vfs-protocol.zig`
> (the `vfs-protocol` module), whose unit test pins a sample of the sizes
> and values below. This page is the **language-neutral wire specification**
> of that contract — what a Rust or C client implements ([vdso.md](../os-development/vdso.md)
> explains why the IPC protocols, not the syscall numbers, are danos's
> public ABI).

## Transport

A VFS exchange is one synchronous IPC rendezvous (`ipc_call`,
docs/ipc.md): the client sends one message and blocks; the server replies
with one message. The endpoint comes from the kernel's `fs_resolve` — which
also hands back the path rewritten relative to the mount — not from a
registry lookup. (Service id 1, the old userspace router, is retired.)

- A message is at most **256 bytes** (`message_maximum`).
- Every packet begins with the 16-byte **envelope prefix**
  ([protocol-namespace.md](../os-development/protocol-namespace.md)): a
  `Header` on a request, a `Status` on a reply. The prefix is **folded, not
  stacked** — the verb and the object being addressed live in it, and no
  request or reply below repeats either.
- A request is the header, then the verb's own fixed part (0–16 bytes), then
  an inline tail of at most **224 bytes** (`maximum_payload`) — a path, or
  write bytes. There is no multi-message request: paths and single
  reads/writes must fit, and larger transfers loop (see *read* / *write*).
- A reply is the status, then the verb's own fixed part, then an inline tail
  — read bytes, or a directory entry's name.
- All integers are **little-endian**; layouts are C layout for x86-64
  (`extern struct`), offsets given below so nothing need be inferred.

The kernel resolves NAMES (the mount table) but never parses these
messages — it moves the bytes; file state is entirely the backend's affair.
With clients holding backend node ids directly, a backend records each open
handle's owner and sweeps a dead client's handles via the published process
exit events.

## Request header — 16 bytes

The envelope's `Header`, identical in every danos protocol:

| offset | size | field | meaning |
|-------:|-----:|-------|---------|
| 0 | 4 | `operation` | an **Operation** value (below); 0–15 are the reserved universal verbs |
| 4 | 4 | — | padding |
| 8 | 8 | `target` | **the open-node id** from a prior `open`; 0 for `open` itself and the path-based verbs |

## Reply header — 16 bytes

The envelope's `Status`:

| offset | size | field | meaning |
|-------:|-----:|-------|---------|
| 0 | 4 | `status` | **0 = success**, negative = failure (signed) |
| 4 | 4 | — | padding |
| 8 | 4 | `len` | reply bytes following this header: the verb's fixed part plus its tail |
| 12 | 4 | — | padding |

A failing backend replies with the status alone (`len` = 0) and no fixed
part, and that reply reaches the client directly — there is no party between
them on the wire. (Kernel-served paths produce no wire replies at all:
`fs_resolve`/`fs_node` failures are syscall register statuses.) The errno
vocabulary is the kernel's, continued by the envelope: `ENOENT` = 4 is what a
backend answers for anything it cannot find or cannot do, `ENOSYS` = 10 for a
verb it does not implement, `EPROTO` = 11 for a packet shorter than the verb
it names. Clients must treat *any* negative status as failure rather than
matching a particular one.

## Operations

Values number from 16 (`first_protocol_operation`) in declaration order, and
are frozen once shipped. Values 0–15 are the envelope's reserved universal
verbs, which mean the same thing at every provider in the system: `describe`
(0) answers the protocol's name and version and is implemented by the
envelope itself, so every backend answers it. A verb outside this table is
answered `-ENOSYS`; it is never a safety check any more, because the
dispatch compares numbers rather than decoding an enum.

Each row's *request* and *reply* name the bytes **after** the 16-byte prefix.

| value | operation | request | tail | reply | reply tail |
|------:|-----------|---------|------|-------|-----------|
| 16 | `open` | `flags` (4 bytes, below) | the path | `node` (8 bytes) = the open-node id | — |
| 17 | `close` | — | — | — | — |
| 18 | `read` | `offset` (8), `len` (4) = wanted count | — | — | the bytes read; `Status.len` 0 at end of file |
| 19 | `write` | `offset` (8), `len` (4) = count | the bytes | `count` (4) = bytes accepted (may be short — loop) | — |
| 20 | `status` | — | — | **FileStatus** (24 bytes) | — |
| 21 | `readdir` | `cursor` (8) | — | one **DirectoryEntry** (16 bytes) | the name |
| 22 | `mount` | — | the mount-point path; the backend endpoint rides as the call's **capability** | — | — |
| 23 | `unmount` | — | the mount-point path | — | — |
| 24 | `mkdir` | — | the path | — | — |
| 25 | `unlink` | — | the path | — | — |
| 26 | `rename` | — | old path, one `0x00`, new path | — | — |
| 27 | `bind` | — | the contract name; the provider's endpoint rides as the call's **capability** | — | — |

Notes per operation:

- **open** — the path is the mount-relative path `fs_resolve` handed back
  (absolute-shaped: `/notes.txt` under fat's `/volumes/usb` mount). Bare names
  (`greeting`) resolve nowhere — the flat ramfs is retired, and `fs_resolve`
  refuses non-absolute paths. The returned `node` is the *backend's* own
  open-node id: with the router in the kernel there is no forwarding table,
  and clients hold backend ids directly (see *Lifetimes and trust*). Every
  later packet carries it in `Header.target` — the path is spoken once, here,
  and integers do the rest.
- **read / write** — a single exchange moves at most 224 bytes
  (`maximum_payload`); the client loops, advancing its own offset by what
  came back, until done (read) or the slice is written (write). A `write`
  reply shorter than requested is progress, not an error; a count of 0 means
  no forward progress — stop rather than spin.
- **readdir** — `cursor` is the **entry index**, not a byte position. Each
  call returns exactly one entry; the client increments the cursor by 1. **A
  `name_len` of 0 is end-of-directory** — the reply's own length cannot say
  so, because the envelope always sends the fixed reply part. The directory
  must have been opened with the `directory` flag.
- **mount / unmount** — RETIRED from the wire: mounting is the `fs_mount`
  syscall now (a filesystem server passes its endpoint handle; possession is
  the capability, exactly the trust of the old cap-passing op). The verb
  numbers stay reserved. Mount-prefix semantics are unchanged: prefixes
  match at path boundaries only (`/volumes/usb` never captures
  `/volumes/usbextra`), the longest matching prefix wins, and an optional
  backend-side rewrite prefix maps a mount into the backend's namespace (fat
  serves `/volumes/usb` from its volume root and `/system/logs` from its
  `/system/logs` subtree).
- **rename** — same-directory rename only: the backend compares the old and
  new parent paths and refuses a mismatch. The client (`file_system`) refuses
  earlier when the two paths resolve to different backend endpoints, but that
  check is coarser than "one mount" — one endpoint can serve several mounts
  (fat serves `/volumes/usb`, `/system/configuration` and `/system/logs`), so
  a cross-mount rename reaches the backend and fails on its same-directory
  check.
- **bind** — the protocol registry's claim verb, implemented only by the
  synthetic `/protocol` backend inside PID 1
  ([protocol-namespace.md](../os-development/protocol-namespace.md)). A file
  backend answers `-ENOSYS`.

## Open flags

Bitwise OR in `open`'s `flags`, meaningful for `open` only:

| bit | name | meaning |
|----:|------|---------|
| 1 | `create` | create the file if it does not exist |
| 2 | `directory` | open a directory node for `readdir` rather than a file |
| 4 | `truncate` | truncate an existing file to zero length on open (replace, don't overwrite in place) |

## FileStatus — 24 bytes (the `status` reply's fixed part)

| offset | size | field | meaning |
|-------:|-----:|-------|---------|
| 0 | 8 | `size` | file size in bytes |
| 8 | 4 | `kind` | a **NodeKind** value |
| 12 | 4 | — | padding |
| 16 | 8 | `mtime` | modification time, Unix epoch seconds UTC; 0 if the backend keeps none |

## DirectoryEntry — 16 bytes + name (the `readdir` reply)

| offset | size | field | meaning |
|-------:|-----:|-------|---------|
| 0 | 4 | `kind` | a **NodeKind** value |
| 4 | 4 | `name_len` | length of the name that follows; **0 means end of directory** |
| 8 | 8 | `size` | the entry's size in bytes |
| 16 | `name_len` | name | the entry's name, not NUL-terminated |

## NodeKind

Aligned to the node-kind table in the file-system hierarchy
(docs/file-system-development/file-system-hierarchy.md):

| value | kind |
|------:|------|
| 0 | regular file |
| 1 | directory |
| 2 | character device |
| 3 | block device |
| 4 | symbolic link |
| 5 | fifo |
| 6 | socket |
| 7 | protocol |

Clients should map unknown values to *regular* rather than reject — the
table can grow. Kind 6 (`socket`) keeps its wire value but is retired from
the design — a named rendezvous point is exactly what a `protocol` node is,
landed as value 7 with the protocol namespace
(docs/os-development/protocol-namespace.md). `character_device` (stream
semantics — the tty/console shape) and `block_device` (raw sector-addressed
volumes, reserved) remain part of the design.

## An open reply may carry a capability

`open` rides `ipc_call`, whose reply direction can hand back an endpoint
capability alongside the reply. A file backend never uses it — FAT
answers with a node id and nothing else — but a **synthetic** backend does:
opening a `protocol` node returns the provider's endpoint, and possession of
that endpoint *is* the channel. The convention is per-backend, not
per-operation, so a client that opens an ordinary file simply receives no
capability, exactly as before.

## Lifetimes and trust

Open-node ids live in the backend. A client that dies without closing leaks
nothing permanently: the backend (the FAT server) subscribes to the kernel's
published process-exit events (docs/process-lifecycle.md) and releases a dead
client's handles. The kernel VFS root needs no sweep at all — its node tokens
are permanent for a boot and carry no open state.

Ids are plain integers rather than capabilities, so the backend **scopes them
to the caller's badge**: an open node belongs to the task that opened it, and
every verb that names one — read, write, status, readdir, close — is answered
only for that task. A node id is a small number drawn from a table of
thirty-two, trivially guessable, and until this rule a backend honoured every
client's ids from every other client
(docs/os-development/protocol-namespace.md: *handles must be scoped per
client — validated against the badge, or drawn from a per-client id
namespace*).

The refusal is deliberately **identical to absence**: a node that is somebody
else's answers `-ENOENT`, exactly as one that was never opened, so a prober
learns nothing about which ids are live — the same discipline the protocol
namespace applies to a refused open. The owner is a *task*, because the badge
is: a threaded client uses a node from the thread that opened it, which is
already the granularity of the exit sweep that releases it.

## Evolution rules

What a non-Zig implementation may rely on, and what it must not:

- Operation values, flag bits, `NodeKind` values, and struct layouts are
  **append-only and frozen once shipped**. The unit test in
  `library/protocol/vfs/vfs-protocol.zig` pins a sample of them (the
  `DirectoryEntry` size, `NodeKind` 0–1 and 6–7, `Operation` values 16–21, 26
  and 27); this page is the full record of the frozen values.
  *The one renumbering this contract has had was the rebase onto the envelope
  (P4a), which moved every verb above the reserved range — a deliberate
  flag-day across a system with no third-party clients yet, not a precedent.*
- The 256-byte message ceiling is a property of the current IPC transport,
  not a promise; clients should read `maximum_payload`-shaped limits from the
  reply lengths they actually get (loop-until-done), not hard-code 224.
- Success is exactly 0, and the negative statuses come from one system-wide
  errno vocabulary (the kernel's, continued by the envelope) rather than from
  this protocol.
