Files
danos/docs/os-development/device-authority.md
T
Daniel Samson 547d0ec46b docs: device authority — the how, and the run that deletes the ceilings
device-authority.md is rewritten as an implementation design rather than a
rival to device-manager.md. The what was already settled there in 2026-07:
structure in the manager, authority in the kernel, and delegation as the
step after hello. This is the how, plus the two decisions that paragraph
leaves open.

Decision 1: the manager claims, it is not granted. device-manager.md says
"claims (or is granted)"; claiming wins because the manager runs before any
driver exists and takes the seeded devices unopposed, leaving nothing unheld
to race for. One new call, device_transfer(device_id, task_id), checks only
that the caller holds the device — no names in the kernel, no attestation.
The alternative put a binary path inside the kernel, and the kernel should
hold only what cannot safely live in user space. The residual is stated
rather than hidden: authority rests on the manager claiming first, which
init.csv makes an operator-visible ordering rather than an attacker-
controlled one, and the enforced version arrives with the spawn capability
drivers.md already names as missing.

Decision 2: the kernel stops holding inventory. It reads three things out of
a descriptor — physical ranges, interrupt numbers, one PCI BDF — and stores
the rest only so device_enumerate can hand it back. Devices with no
resources leave the kernel entirely: a USB device conveys no mapping
authority, so there is nothing to enforce. That is also the case which
sidesteps containment, and therefore the reason a shared cap existed.

Decision 3: no shared ceiling. The table becomes dynamic — it is built after
heap.init, so nothing ever prevented it — and the two invented numbers go. A
per-holder quota replaces them, because dynamic storage with no bound moves
the ceiling to the kernel heap, which is shared and fatal rather than
partial. A bound charged to whoever caused it is isolation.

Two earlier drafts of this document are gone: one gave init the root grants,
the other proposed extracting a firmware-framebuffer driver. Both were wrong
and both are recorded as wrong in the run plan's settled list — the
framebuffer is not a device, it is where pixels go until a real display
driver announces itself.

Run 2 is nine steps, ordered so the suite stays green throughout: build and
prove the transfer mechanism, move the five claimants across one at a time,
then the flag day, then the inventory, then the ceilings.
2026-08-08 17:01:25 +01:00

152 lines
8.1 KiB
Markdown

# Device authority: the implementation of delegation
*Implementation design, 2026-08-08. The **what** is settled in
[device-manager.md](../device-driver-development/device-manager.md) — "structure in the
manager, authority in the kernel", and delegation as the step after `hello`. This
document is the **how**, and the decisions that paragraph leaves open.*
Read first: [drivers.md](../device-driver-development/drivers.md) (the claim is the
capability), [driver-model.md](../device-driver-development/driver-model.md) (the three
invariants), [device-manager.md](../device-driver-development/device-manager.md) (the
tree, the matcher, the supervisor).
## The one thing not yet true
`device-manager.md` says assignment "stays argv for now", and names the next step:
> The step after `hello` exists is delegation: the manager claims (or is granted) the
> devices and passes the claim to the driver over IPC (the M13 capability-transfer
> mechanism), replacing first-come-first-served `device_claim` with policy.
Until that lands, the manager's matching is advisory. `device_claim` checks only that
the device exists and is unheld ([devices-broker.zig](../../system/kernel/devices-broker.zig)):
```zig
pub fn claim(id: u64, owner: u32) ClaimError!void {
if (id >= count) return error.NoSuchDevice;
if (claimed[@intCast(id)] != null) return error.AlreadyClaimed;
claimed[@intCast(id)] = owner;
}
```
A driver is spawned with its device id in `argv[1]` and claims it; any process could
pass any integer instead. Since a claim is a licence to map physical memory, that is the
gap this document closes.
## Decision 1: the manager claims, then transfers
`device-manager.md` leaves "claims (or is granted)" open. **Claims.**
The manager runs before any driver exists — `init` starts it from `init.csv`, and it is
what spawns drivers — so it takes the seeded devices unopposed and there is nothing
unheld left for anyone to race for. One new call moves ownership on:
```
device_transfer(device_id, task_id) -> 0/-errno
```
The kernel checks only that the caller currently holds the device. No names, no
attestation, no notion of "the device manager" — the rule is *you may give away what you
hold*, which is the capability discipline already in force.
The alternative was the kernel granting roots to a task it recognises by binary path
plus a PID-1 supervisor. It is more robust — it does not depend on the manager being
first — but it puts a binary name inside the kernel, and the goal is that the kernel
keeps only what cannot safely live in user space. A name is not that.
**The residual, stated plainly:** authority here rests on the manager claiming first.
That holds because `init.csv` decides what starts and in what order, so it is an
operator-visible ordering rather than an attacker-controlled one — but it is an
assumption, not an enforced invariant. The enforced version arrives with the spawn
capability [drivers.md](../device-driver-development/drivers.md) already names as
missing ("`system_spawn` is currently ungated … because there is no spawn capability
yet"). This design is compatible with it and does not block on it.
## Decision 2: the kernel stops holding inventory
The kernel reads exactly three things out of a descriptor: **physical ranges** (to check
a mapping falls inside one), **interrupt numbers**, and **one PCI BDF** (to key an IOMMU
domain). Vendor, device and subsystem ids, class triples, `_HID` strings, bus addresses
and names are stored only so `device_enumerate` can hand them back — which
`device-manager.md` already resolves: that call "fades to a manager-internal (then
deleted) seam", because the manager owns the tree as data.
So the kernel's table becomes: **parent, resources, holder, BDF.** That is what cannot
safely run in user space; the rest moves.
**Devices with no resources leave the kernel entirely.** A USB device is addressed
through its controller and carries `resource_count = 0`
([driver-model.md](../device-driver-development/driver-model.md): "that case is allowed
and is the common one"). It conveys no mapping authority, so there is nothing for the
kernel to enforce and no reason for it to know. It is inventory, and inventory is the
manager's — reported by `child_added`, which already carries everything needed.
That is also the case that made `maximum_children_per_parent` necessary: a zero-resource
child sidesteps containment, so a driver could loop `device_register` and fill the
shared table. Once such children are not kernel objects, every remaining entry is a real
contained subdivision of something the caller holds.
## Decision 3: no shared ceiling; a per-holder quota instead
`maximum_devices = 64` and `maximum_children_per_parent = 16` are numbers we invented,
and both are shared — one driver's enumeration starves every other driver, which is how
an AMD Ryzen booted with no USB and no storage.
- **The table becomes dynamic.** It is built after `heap.init` (`kernel.zig`: `pmm.init`
at 137, `heap.init` at 179, `devices_broker.init` at 202), so nothing prevents it. No
specification bounds how many devices a machine has, so nothing should bound ours.
- **`maximum_children_per_parent` is deleted**, because the authorisation it stood in
for now exists.
- **A per-holder quota replaces them.** Dynamic storage without a bound moves the
ceiling to the kernel heap, which is shared and fatal rather than partial — strictly
worse. The bound that is *not* worse is one charged to the task that caused it: a
driver that loops `device_register` exhausts its own allowance, is refused with an
attributable errno, and is restarted by its supervisor while every other driver
carries on. That is the microkernel property rather than a workaround for it, and it
is declared through [bounds.md](bounds.md) like any other.
## The shape of the change
| | Before | After |
|---|---|---|
| Manager gets its devices | claims them, unauthorised | claims them (first, unopposed) |
| Driver gets its device | `argv[1]` + `device_claim` | receives it in the `hello` reply |
| Kernel checks | is it free? | do you hold it? |
| Kernel stores | the full descriptor | parent, resources, holder, BDF |
| Zero-resource devices | kernel table entries | manager records only |
| Table size | `maximum_devices = 64` | dynamic, per-holder quota |
| Children per parent | `maximum_children_per_parent = 16` | deleted |
Bring-up order changes for the five claiming drivers: `hello` must precede the claim,
because the reply is where the device arrives. `pci-bus` today does the reverse — its
own comment reads "Claim the bridge, map the ECAM, hello the manager, then scan."
## What does not change
- The three invariants of [driver-model.md](../device-driver-development/driver-model.md):
a claim is exclusive, a descriptor is a licence to map physical memory, therefore
containment. This design strengthens the first and touches neither of the others.
- The display service's GOP path. The framebuffer is not a device — it is where pixels
go, handed over by the loader, and the compositor uses it as the boot floor until a
real display driver announces itself
([display-v2.md](../device-driver-development/display-v2.md)). The kernel wraps it in
a display-class descriptor so `mmio_map` can hand it over write-combining; that is
plumbing for a mapping, not a claim about what it is.
- Supervision, restart, pruning and re-report
([device-manager.md](../device-driver-development/device-manager.md),
[process-lifecycle.md](process-lifecycle.md)). Delegation slots into the existing
`hello` exchange and changes none of it.
- `device_register` idempotency, which is what lets a restarted bus rebuild the same
ids.
## How it is verified
The invariant is: **a process holds what it was handed and cannot name its way into
holding more.** The suite has no adversarial device case today — the audit's lesson was
that "the suite contains no attacker" — so this adds one: a process that was handed
nothing calls `device_claim` and `device_transfer` on a device another driver holds, and
on one nobody holds, and is refused each time with its own errno.
The Ryzen is the acceptance test for the ceiling half: it is the machine that found the
constants, and the one that proves them gone.