danos/docs/os-development/protocol-namespace.md

25 KiB
Raw Blame History

The protocol namespace

Design, agreed 2026-07-31. Supersedes the ServiceId registry. Not yet implemented — the migration plan at the end is the 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 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 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).

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 /systemfs_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).

/// 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:

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); 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 streamsubscribe (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_changedenumerate. 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.
  • 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) is independent of P1P5 and can land before or after.