# The protocol namespace

*Design, agreed 2026-07-31. Supersedes the `ServiceId` registry. P1–P3 of the
migration plan at the end have landed (the envelope, the registry and the
`ServiceId` flag-day, and restriction stage one); P4 and P5 are the remaining
work list.*

How a program finds, connects to, and is restricted from the things it talks to.
Three ideas, kept deliberately separate:

1. **Naming** — a path under `/protocol` names a *contract*, not a service.
2. **Access** — resolving that path yields an endpoint *capability*; what a process
   cannot resolve, it cannot reach.
3. **Transport** — unchanged: packets over channels, moved by whichever
   transport the channel rides (kernel-ipc first).
   This document is layers **L3** (the namespace) and **L2** (the protocol
   and its envelope) of the communication stack;
   [communication.md](communication.md) owns the model and the vocabulary
   (*protocol* the language, *channel* the conversation, *packet* the
   transmitted unit, *signal* the payload-less poke, *transport* the
   replaceable mechanism), and
   [ipc.md](../device-driver-development/ipc.md) is the first transport.

## Why ServiceId has to go

Today a service calls `ipc_register(service_id, endpoint)` and a client calls
`ipc_lookup(service_id)`, where `ServiceId` is a compile-time enum in `abi.zig`
backed by a flat 16-slot table in the kernel. Three defects, in rising order:

- **Static.** The id space is baked into the ABI at compile time. A third-party
  program can never introduce a service; the one place danos is *less* dynamic
  than its own design.
- **Ungated.** `ipc_register` is callable by any process and *replaces* an
  existing registration. Any process can hijack `.fat` or `.display` and
  impersonate it. `ipc_lookup` is equally ambient.
- **Unrestrictable.** Because lookup is a syscall available to everyone, there is
  no point at which "this process may not talk to the display" can be enforced.
  Any future file-access restriction would be bypassable by speaking to the FAT
  server directly.

## Naming: contracts, not services

`/protocol/<name>` names a protocol — the contract a conversation follows — and
resolving it connects you to whatever process currently provides that contract.
The client never cared *which* binary answers; it cares that its messages are
understood. Naming the contract makes that explicit, and buys:

- **Swappable providers.** Replace the display server; `/protocol/display`
  routes to the new one; clients notice nothing.
- **Test fakes.** Spawn a program whose namespace wires `/protocol/display` to a
  mock. The name promises the protocol; the mock speaks it.
- **One vocabulary.** The names mirror `library/protocol/`: a program imports
  the `display-protocol` module, then opens `/protocol/display`. What you
  compiled against and what you ask the namespace for are the same word.

A leaf names one contract — kebab-case, full words, matching the
`library/protocol/` module that defines its wire format — and related
contracts group into directories: `/protocol/networking/ip`,
`/protocol/networking/bluetooth`. Directories organize *contracts only*;
they never encode addressing (see below), so a directory appears because a
domain has several contracts, never because hardware multiplied. The module
tree mirrors the namespace (`library/protocol/networking/ip` ↔
`/protocol/networking/ip`), and registrar grants scope naturally to subtrees
— an application installed at `/applications/foo` can be granted
`/protocol/applications/foo/...` and nothing above it. `/protocol` is
top level, beside `/system` and `/applications`, because the boundary it names
is spoken on both sides: applications talk to protocols as much as the OS does
(see [file-system-hierarchy.md](../file-system-development/file-system-hierarchy.md)).

**Addressing lives inside the protocol, never in the path.** Which volume, which
layer, which input device — that is a destination field in the messages, the way
TCP carries a destination address, and the way danos protocols already work (the
display protocol multiplexes layer ids; the vfs protocol addresses node ids).
The namespace answers exactly one question — *may this process speak this
protocol at all* — so `/protocol/block` is one name no matter how many disks are
attached. The source address is never in the message either: it is the IPC
badge, stamped by the kernel per message, unforgeable — a property TCP's source
address does not have.

`/system/devices` (the device inventory) stays purely informational: facts for
diagnosis, never a routing mechanism. Unix conflated the two in `/dev`; danos
does not. You *read about* hardware in `/system/devices`; you *talk to* it
through `/protocol`.

## Resolution: a protocol node in the VFS

The kernel VFS router already does the hard part: `fs_resolve` matches a mount
prefix and installs the backend's endpoint capability in the caller's handle
table. The registry is just a backend mounted at `/protocol` — ring 3, like FAT.
Connecting is a normal vfs-protocol `open` with one twist in the reply:

```
client                      kernel router                registry backend
  │ fs_resolve("/protocol/display")                             │
  │──────────────────────────▶│ prefix match: /protocol         │
  │◀── registry endpoint ─────│ (capability installed)          │
  │ vfs open("display") ──────────────────────────────────────▶│
  │◀───────────────── Reply + capability = provider endpoint ──│
  │ ipc_call(provider, display-protocol messages...)            │
```

Both capability moves use machinery the kernel already has: request-direction
and reply-direction `send_cap` on `call`/`replyWait`. The vfs protocol needs two
additions, both append-only:

- `NodeKind.protocol` — a node that names a contract; its `open` establishes
  a **channel** (delivered as an endpoint capability) instead of returning a
  file id. The node is the protocol, the channel is the conversation, and the
  addressing inside the packets decides where within the provider each one
  lands. `readdir` over `/protocol` lists protocol nodes like any others, so
  the tree stays browsable for diagnosis.
- The convention that an `open` reply may carry a capability. File backends
  (FAT) never use it; synthetic backends (the registry, later the device
  inventory) do.

The path lookup happens once, at connect time. The hot path — `ipc_call` on the
cached endpoint — is untouched. A provider crash turns the cached endpoint dead
(`-EPEER`), and the client's recovery is to re-resolve: the restart story falls
out of the naming layer for free.

## Registration: the registrar, held by init

The registry backend is **init**. It is already PID 1, already spawns every
service from its manifest, and already holds the supervision link to each — it
is the process that *knows* which binary is which. (If init grows
uncomfortable, the same design lifts into a dedicated registry service that
init spawns first and delegates to; nothing below changes.)

- **Binding.** A service creates its endpoint and sends the registry a `bind`
  request with the protocol name as payload and the endpoint attached as the
  call's capability.
- **Authorization.** Init's manifest gains a column: the protocols each spawned
  binary may bind. A `bind` from any process not granted that name is refused
  (`-EPERM`) — the badge identifies the caller, the supervision records map
  badge to binary. This is the registrar authority; it never leaves init.
- **Collision is an error.** A name already bound refuses a second bind — never
  last-writer-wins. When a provider dies, init (its supervisor) unbinds its
  names; the restarted instance binds again.
- **Provenance.** The registry records name → task id → binary path, so a
  diagnostic listing answers "who serves this?" at a glance:

  ```
  /protocol/display   pid 12   /system/services/display
  /protocol/input     pid 7    /system/services/input
  ```

`ipc_register` and `ipc_lookup` retire; the `ServiceId` enum leaves `abi.zig`.
The kernel keeps one residual rule: `/protocol` becomes a reserved prefix like
`/system` — `fs_mount` refuses to shadow it, and init's boot-time mount is the
only one it will ever hold. (Full gating of `fs_mount` is a separate item on
the security track; the reserved prefix closes the hole for this namespace
without waiting for it.)

## Restriction: per-process namespaces, not ACLs

danos has no users and no principals, deliberately. Restriction is therefore
**delegation**: what a process may open is decided by whoever spawned it, and
enforcement is absence — a protocol you cannot resolve does not exist for you.
"Permission denied" and "not found" are the same answer, which is the same
discipline the device layer already follows: the claim is the capability; here,
the resolvable name is the capability.

Two stages, deliberately ordered so the useful half lands first:

**Stage one — the registry filters by badge.** Init is both the spawner and the
registry, so its manifest already knows which binary may *open* which protocols
(a second manifest column, beside the bind grants). An `open` from a process
whose binary is not granted that protocol is refused. No new kernel mechanism
at all; the display driver's view can be narrowed to nothing, a future
downloaded application's to `display` and `input`, today.

**Stage two — spawn passes the namespace.** `spawn` gains an initial
capability: the child's connection to *its* registry view, chosen by the
spawner. A newly spawned process starts with an empty handle table and this one
handle — its world is whatever its parent wired in. This removes the last
ambient reach (`fs_resolve` finding `/protocol` globally), lets any supervisor
— not just init — narrow or fake a child's view (an application launcher
granting an app only what its manifest declares; a test harness substituting
every provider), and composes down the supervision tree. Stage one's manifest
column becomes the *content* of the view init builds, so nothing is thrown
away.

### A worked example: the microphone prompt

The scenario stage two exists for: an application opens
`/protocol/audio-input`, and the user should be asked. The supervisor is an
ordinary user process — an application launcher — and the flow needs no new
security concepts:

1. The launcher spawned the app with a namespace channel that terminates at
   **the launcher itself**. The app's whole world is a conversation with its
   supervisor.
2. The app's `open("audio-input")` packet lands in the launcher,
   badge-stamped. The launcher spawned the app, so badge → binary path
   (`/applications/foo`) is its own supervision record — "remember my choice"
   needs no identity system.
3. Grant unknown → the launcher parks the request and shows a prompt (it is a
   user process with display access; init never does UI). Blocking an open on
   a human is architecturally fine: opens are connect-time, never hot-path.
4. **Yes** → the launcher opens `/protocol/audio-input` in *its own*
   namespace and attaches the resulting channel to the parked reply. The app
   cannot tell a prompt happened — a consented open is indistinguishable from
   a direct one, merely slower.
5. **No** → refuse the open, indistinguishable from "no such protocol" — or
   hand the app a **fake**: a silence-generating provider. The test-fake
   mechanism doubles as a privacy feature.

The capability discipline holds throughout: the launcher can only grant what
it holds — if init never gave the launcher `audio-input`, no prompt can
conjure it. Consent is delegation flowing down the supervision tree, never a
global ACL edit. And the provider still sees the app's badge on every packet,
so a coarser second check at the audio service remains possible.

Two mechanical requirements this scenario pins on stage two:

- **Parked replies.** A prompt takes seconds, and the service loop holds one
  outstanding reply today — the launcher must park request A, keep serving B
  and C, and reply to A later (by badge). The kernel already tracks owed
  replies (that is how death delivers `-EPEER`); multiple parked replies is
  the extension, in the harness and, if needed, the kernel.
- **Granted channels are dedicated, hence revocable.** Once the app holds a
  channel capability, nobody reaches into its handle table — so a
  prompt-granted channel must be one that can be *killed*: a dedicated
  endpoint pair (or per-client session at the provider) whose death turns
  the app's capability into `-EPEER`. Revoking microphone access is then
  killing that channel, using machinery that already exists.

One adjacent problem, named and deferred: **trusted UI**. The prompt is only
meaningful if the app cannot draw a convincing fake or overlay the real one —
a display-layer question (a reserved surface for the supervisor chain), owned
by the display track, not this one.

Fine-grained restriction *within* a protocol (this process may use volume A but
not volume B) is not the namespace's job. The capability-shaped answer, when it
is needed: the supervisor pre-opens a connection scoped to one target and passes
that connection to the child, which never opens `/protocol/block` at all.
Delegation again, not ACLs.

## The envelope: one addressing scheme for every protocol

Every protocol module today hand-rolls its `Request`/`Reply` with an
`operation` first field. That convention becomes a library, so addressing is
uniform and the rules are enforced by construction rather than by review. New
module: **`library/protocol/envelope`** (the one protocol-layer module that is
not itself a protocol).

```zig
/// Every packet a danos protocol transmits begins with this header.
pub const Header = extern struct {
    operation: u32, // the verb; values 0..15 are reserved universal verbs
    _padding: u32 = 0,
    /// Object addressing, never party addressing: which of the peer's
    /// objects this packet operates on — a volume, layer, node, device.
    /// 0 addresses the provider itself. Parties are addressed by the
    /// channel; the protocol defines target's meaning; the field's place
    /// and width are universal.
    target: u64 = 0,
};

/// Reserved verbs, answered by every provider.
pub const operation_describe: u32 = 0; // -> protocol name, version, target kinds
pub const operation_enumerate: u32 = 1; // -> the current targets, one per reply page
pub const operation_subscribe: u32 = 2; // capability = the subscriber's endpoint
pub const operation_unsubscribe: u32 = 3;
pub const first_protocol_operation: u32 = 16;

/// Every reply begins with this.
pub const Status = extern struct {
    status: i32, // 0 or a negative errno
    _padding: u32 = 0,
    len: u32 = 0, // payload bytes following the header
    _padding2: u32 = 0,
};
```

A protocol is then *defined through* the envelope, not beside it:

```zig
pub const Protocol = envelope.Define(.{
    .name = "display",
    .version = 1,
    .operations = &.{
        .{ .name = "configure_layer", .request = ConfigureLayer, .reply = void },
        .{ .name = "blit", .request = Blit, .reply = void },
        ...
    },
});
```

`Define` is comptime and is where the enforcement lives:

- verbs are numbered automatically from `first_protocol_operation`, so no
  protocol can collide with the reserved range;
- every packet is size-checked at compile time against the kernel-ipc floor
  — `packet_maximum` (256) for request/reply, `post_maximum` (64) for event
  packets. Ceilings are transport properties
  ([communication.md](communication.md)); the floor is what every protocol
  may assume on any transport. The errors that today surface as runtime
  truncation become compile errors, and packets-never-fragment is enforced
  at the source;
- the generated type carries encode/decode helpers and a provider-side dispatch
  table, so a provider answers `describe` automatically and unknown operations
  with `-ENOSYS` uniformly;
- the service harness (`library/kernel/service.zig`) accepts the generated
  dispatch type, which is what makes the envelope *enforced*: a protocol that
  bypasses `Define` does not plug into the harness.

Universal conventions that ride on the reserved verbs:

- **`describe`** is the version handshake. Version lives in the handshake, not
  in every message — the 256-byte budget is too small to spend per call.
- **`enumerate`** is how multi-target protocols expose their targets, and the
  standard `targets_changed` notification (a notify bit) tells subscribers to
  re-enumerate — arrival and removal of volumes, layers, devices all take the
  same shape. Hotplug fits the notification ring far better than a filesystem
  tree ever did.
- **Source is the badge.** No protocol defines a "sender" field; the kernel's
  per-message badge is the only source identity, and providers key per-client
  state on it.

### Paths resolve once; integers do the work

A rule the envelope makes official: **a path appears in a conversation at most
once — at resolve or open — and everything after it addresses integers.** The
namespace resolves `/protocol/display` to an endpoint; a backend's `open`
resolves a path payload to a node id; from then on every packet carries the
integer in `target`. Integers compare in one instruction and fit the fixed
header, and the 256-byte message budget never re-carries path strings on the
hot path. This is already the system's shape — vfs node ids, display layer ids
— and the envelope pins it as the required shape for every protocol.

Two integer identities, not to be confused:

- **An open handle** — what vfs `open` returns today: transient, meaningful
  only within one client's session with one provider, swept when the client
  exits. Cheap, and all a protocol usually needs. Handles must be **scoped per
  client** — validated against the badge, or drawn from a per-client id
  namespace. (Today the FAT server's node ids are guessable small integers
  honoured across clients; that hole closes with this rule.)
- **A persistent node identity** — a unix inode number, stable across opens
  and renames. danos deliberately does not promise this, because FAT cannot
  deliver it: a FAT file's identity is its directory entry, and rename or
  truncation moves every candidate anchor. If a future filesystem or a cache
  layer needs stable identity, that is the backend's promise to make, never
  the protocol's assumption.

The five existing protocol modules (`vfs`, `display`, `input`, `power`,
`block`, plus `scanout`, `usb-transfer`, `device-manager`) rebase onto the
envelope during the migration flag-day. `input-protocol`'s subscribe/publish
split and `vfs-protocol`'s node addressing both map cleanly (`node` and layer
ids become `target`).

## Wiring: how conversations flow

The patterns below are channel-layer (L1) shapes; the delivery mechanics are
the kernel-ipc transport's, described here because it is the transport every
channel starts on. Kernel-ipc provides exactly three delivery shapes, and
every one is unicast. An endpoint is a mailbox owned by one process — its
creator receives; anyone holding its capability sends into it. That direction
never reverses:

1. **Synchronous call** — request/reply. The kernel parks the caller and
   `replyWait` delivers the reply straight back, so the provider answers
   without holding any capability to the client. Badge-stamped, blocking, and
   the *only* shape that carries capabilities (in the request, and in the
   reply — which is how a reverse path is bootstrapped).
2. **Asynchronous send** — an event packet pushed into the receiver's post
   ring, at most `post_maximum` (64) bytes, no reply owed, never blocks the
   sender. Strictly one-way: to be pushed to, you must first hand the pusher
   your endpoint.
3. **Signals** — payload-less notification bits, below the packet layer,
   coalescing: "something changed, come look."

A bidirectional link is therefore always **a pair of endpoints**, one per
direction, each delivered by cap-passing. Three conversation patterns are
built from these, and the envelope names all three:

- **Request/response** — the synchronous call. The default, and the only
  place capabilities move.
- **Event stream** — `subscribe` (a synchronous call whose attached
  capability is the subscriber's own endpoint), after which the provider
  pushes events asynchronously; `unsubscribe` or subscriber exit ends it.
  Listened-to, not blocked-on.
- **Change signal** — a signal plus re-read: `targets_changed` →
  `enumerate`. For state whose truth lives with the provider.

**Broadcast is a provider pattern, never a kernel primitive.** The kernel
does not know subscriber sets — a service does. The input service is the
model: sources *publish* (a unicast call to the service), the service
*broadcasts* (a fan-out loop of asynchronous sends over its subscriber list,
so one dead subscriber can never stall the rest). One fan-out point per event
domain, owned by the service that defines the event.

The harness owns the machinery: the subscriber table, the dead-subscriber
sweep (via process-exit notifications), and the fan-out loop — all written by
hand in `input.zig` today, lifted into the service harness so every protocol
gets identical semantics. `Define` declares a protocol's events (`.events`),
and each event type is checked against `post_maximum` at compile time,
generalizing the assert `input-protocol` already carries.

**Event packets are droppable.** A slow subscriber's ring fills, and the
provider must not block on it — so an event stream is a hint or a coalescing
signal, never a ledger. Anything that must not be lost is either re-readable
state (the change-signal pattern) or bulk data in shared memory with a
packet as the doorbell, which is how the display path already works — the
packets-never-fragment rule and this one are the same rule seen from two
sides.

**Source direction (open point).** Today event sources are *clients*: an
input driver resolves `/protocol/input` and delivers each event as a
synchronous `publish` call — one capability, obtained by resolution, covers
everything, and the badge tells the service exactly who each event came from.
The inversion — the service subscribing to each driver — would require every
driver to be individually discoverable and its endpoint ferried to the
service, machinery whose payoff (the service choosing its sources) the
namespace already provides more cheaply: only a process granted open on
`/protocol/input` can publish into it. Sources stay clients for now;
revisited at restriction stage two, when a supervisor can wire capabilities
at spawn time.

## What this deliberately does not solve

The wider security track, for which this namespace is the foundation, not the
whole:

- **File access restriction** — the point of the exercise. The same stage-two
  namespace mechanism extends from protocol names to file paths: the spawner
  decides which subtrees resolve. Designed separately once this lands.
- `fs_mount` gating beyond the reserved prefixes; `system_spawn` gating;
  `klog_read` being world-readable; backends checking the badge on per-node
  operations (the FAT server honours node ids across clients today).
- Kernel hardening items already noted in-tree: SMEP/SMAP and SYSRET
  canonical-RIP, now designed in [smep-smap.md](smep-smap.md).
- Pipes/FIFOs for the POSIX layer — a byte-stream object *beside* message IPC,
  wanted by the Python track, unrelated to naming.
- **Trusted UI** — a permission prompt an application cannot fake or overlay
  (see the microphone example). A display-track concern: the supervisor chain
  needs a reserved surface.

## Migration plan

Flag-day per phase, in the style of the DMA-capability conversion — no
dual-stack periods, the QEMU suite green at each phase boundary.

**P1 — mechanics, no behavior change.** The `envelope` module with its comptime
`Define`, unit tests; `NodeKind.protocol` and the open-reply-capability
convention in `vfs-protocol`; existing protocols untouched.

**P2 — the registry.** Init serves `/protocol` (bind with manifest
authorization, collision refusal, unbind on provider death, provenance);
kernel reserves the `/protocol` prefix; every service converts from
`ipc_register` to `bind`, every client from `ipc_lookup` to resolve-and-open;
`ServiceId`, `ipc_register`, `ipc_lookup` deleted. Tests: unauthorized bind
refused, collision refused, provider restart re-binds and a client re-resolves.

**P3 — restriction, stage one.** The open-grant column in init's manifest;
registry refuses ungranted opens. Test: a fixture process denied a protocol its
neighbour is granted.

**P4 — protocol rebase.** Existing protocol modules re-expressed through
`Define`; providers move onto the generated dispatch; `describe`/`enumerate`
answered everywhere; the conformance test fixture exercises the reserved verbs
against every registered provider.

**P5 — restriction, stage two.** Spawn's initial capability; namespace views
built by the spawner; ambient resolution of `/protocol` retired. Includes the
two requirements the microphone example pins: **parked replies** (a
supervisor parks an open, keeps serving, replies later by badge) and
**dedicated, killable granted channels** (revocation = channel death →
`-EPEER`). Scoped separately — it touches `spawn`, the loader contract, and
every supervisor — and lands together with the file-path half of namespacing.

The unix-path migration ([file-system-hierarchy.md](../file-system-development/file-system-hierarchy.md#migration))
is independent of P1–P5 and can land before or after.
