# 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/` 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.