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

473 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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](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 P1P5 and can land before or after.