P4 of docs/establishment-planes-plan.md. The new usb-two-controllers case is
the Ryzen mouse bug pinned in the suite: a second xHCI controller with its
own keyboard while the boot controller keeps the default one — both must
come up, on different device ids, which requires each class driver to reach
ITS OWN controller. Discrimination: at 72807c2 (name-based establishment)
the case fails — one keyboard is unreachable, exactly the bench failure —
verified against a checkout of that merge; with lineage routing it passes.
The existing second-controller cases could not prove this: the boot bus
always carries a keyboard, so their expects were satisfiable by it.
Docs updated with the code: device-manager.md (hello moves the channels,
paged enumerate, delegation as built, reap-and-rebuild in the restart
sequence), device-authority.md (the fourth as-built decision: driver-layer
channels ride the hello; hello is no longer only the liveness handshake).
Full suite: 118/119, the one failure being iommu-fault's fixed 200 ms
fault window under end-of-run host load — 3/3 green standalone, deflake
flagged separately. usb-two-controllers passed inside the full run.
11 KiB
Device authority: the implementation of delegation
Implementation design, 2026-08-08. The what is settled in
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 (the claim is the capability), driver-model.md (the three invariants), 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
helloexists 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-serveddevice_claimwith policy.
Until that lands, the manager's matching is advisory. device_claim checks only that
the device exists and is unheld (devices-broker.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 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: "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.initat 137,heap.initat 179,devices_broker.initat 202), so nothing prevents it. No specification bounds how many devices a machine has, so nothing should bound ours. maximum_children_per_parentis 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_registerexhausts 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 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: 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). The kernel wraps it in
a display-class descriptor so
mmio_mapcan 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,
process-lifecycle.md). Delegation slots into the existing
helloexchange and changes none of it. device_registeridempotency, 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.
As built (2026-08-09)
Three details settled differently, or beyond, what the sections above say:
- The device arrives with the spawn, not in the
helloreply.system_spawngrew a sixth argument: the manager names the device it is giving, the kernel verifies the caller holds it before the child exists, and the child holds it before its first instruction. A give that fails after the spawn (the device stopped being the giver's, or its confinement was refused) kills the child — a driver running without the hardware it was spawned for is worse than no driver.hellostays what it was: the liveness handshake. - A given device is a loan. When the holder dies, the device returns to the giver if the giver is still alive — so a respawned driver is handed the same device by its manager instead of racing anyone for a released claim. Only if the lender is also dead does the device become unheld.
- Confinement moves with the device, and is rebuilt when the loan comes back. A
transfer re-points the existing IOMMU record; but a death tears the domain down
before the loan returns, so the next delegation of that device finds no record and
confines afresh — under the same fail-closed rule as a first claim (
ECONFINE, the give does not stand). Without that, one driver crash left its device silently unconfined forever. Both give paths (device_transferand spawn's give) share one body in process.zig (giveDeviceLocked), and theiommukernel test drives the death-and-respawn sequence against it directly.
A fourth followed on 2026-08-09, when a real three-controller machine broke the last
name-shaped assumption (communication.md "Establishment: two
planes, one namespace"): driver-layer channels ride the same hello. A driver's
serving endpoint goes up as the hello's capability; consumers get their provider's
channel down in the reply, routed by the manager's lineage — the reporter for a
class driver, the bound driver for a .consumer. usb-transfer and block
stopped being registry names, and a reporter's death now reaps its class-driver
subtree so the re-report can rebuild it against the successor — the manager half of
the loan story above. One correction to the earlier bullet: hello is no longer
only the liveness handshake; it is also where establishment happens. The device
itself still arrives with the spawn.