Files
danos/docs/os-development/bounds.md
T
Daniel Samson a86559648e kernel: a refusal names its rule, and two bounds stop failing open
An AMD Ryzen booted to a working compositor with no USB and no storage,
and the log said only "register refused". A tree-wide audit of every
compile-time ceiling followed: 235 of them, 139 on quantities the machine
or a file decides rather than us, 5 documented anywhere, 171 silent when
reached. docs/fixed-bounds-audit.md has the inventory.

Errno attribution. The errno space was split between the kernel and the
envelope, free to drift; it is now one list in system/abi.zig, restated on
both sides, with a comptime check in library/device/driver where the two
halves are visible. device_register's six refusals and device_claim's three
are distinct codes, so a bus driver can say which rule stopped it, and
BadParent splits into NoSuchParent and NotYourParent. pci-bus reconciles
found against registered instead of counting refused functions as found.

Idempotency ordering. The child cap was checked before the identity match,
so a restarted bus was refused its own devices — the supervision restart the
system leans on ratcheted toward a degraded machine. A re-registration
consumes no slot and is now admitted first.

IOMMU fail-closed. confineDevice returned success for a device id past the
confinement table, leaving the device outside every domain while the caller
believed it confined — unreachable only while ids stop at 64, which both the
inventory move and a hardware-reported domain count would change. It refuses
now, and the coupling to the broker's device cap is a comptime assert rather
than a sentence in a comment.

PCI apertures. The bridge's MMIO apertures are derived from the holes in the
firmware memory map, and the derivation copied sub-4 GiB entries into a
fixed [64] array and skipped the rest. A skipped region is not merely lost:
the gap finder concludes it is free, so a real machine's 60-200 entry map
yields an aperture over live RAM, and containment then admits a child BAR
covering kernel memory. Rewritten to walk the map in place, with the hole
finder extracted as a pure function and driven by a synthetic 100-entry map
in a new test case. Both new tests were verified to fail on the old code.

parameters.zig gains the rationale it was missing and loses a stale sentence
pointing at the wrong file; vdso.md documents the errno space, including
EPEER, which had no written meaning anywhere.

docs/os-development/bounds.md is how a ceiling is declared from here.
docs/bounds-track-plan.md is the plan to remove the ones we invented.

Suite 114 -> 115.
2026-08-08 11:09:54 +01:00

6.8 KiB

Bounds: how a ceiling is declared

Design, 2026-08-08. Follows fixed-bounds-audit.md, which found 235 compile-time ceilings in this tree: 139 on quantities we do not choose, 5 recorded anywhere with their reasoning, and 171 that pass in silence when reached.

A bound is a number chosen at compile time that decides how much of something the code can hold. const maximum_devices = 64. var below: [64]Range. var blob: [512]u8. Different units — devices, firmware memory-map entries, bytes of a USB descriptor — but one shape, and one recurring way of going wrong.

Where a bound lives

Where the thing it bounds lives. A driver's transfer-ring size belongs to that driver; a protocol's payload cap belongs to that protocol; the kernel's task-table size belongs to the kernel. There is no central list and this document does not propose one.

system/parameters.zig is not a counter-example. It is kernel-only, and it exists for a specific historical reason: tunables had accumulated inside the loader↔kernel handoff contract, and splitting them out kept that contract to what it actually is. It is a tidying of one file's contents, not a registry the rest of the system reports to.

This matters for the mechanism below. An earlier draft had every bound declared through a shared bounds module — which would have meant adding a dependency to roughly eight package manifests, including library/protocol, which deliberately depends on nothing. That is a coupling the problem does not require: a bound is a local fact about local storage, and the only thing worth sharing is the shape of the statement, not a module.

The declaration

A structured doc comment, immediately above the declaration, in the file that owns it:

/// bound: logical CPUs the kernel tracks
/// decided-by: hardware
/// protects: the per-CPU bookkeeping arrays, which are sized at compile time
/// at-limit: degrade — surplus cores are left parked, never brought online
/// observed-by: platform.cpusDropped() -> the WARNING at kernel.zig:281
pub const maximum_cpus = 128;

Five fields, all mandatory:

Field Answers
bound what is counted, in plain words
decided-by hardware, external, or ours — who chooses how large it gets
protects what this ceiling defends against
at-limit refuse / degrade / truncate / grow, and the detail
observed-by how an operator finds out it was reached

decided-by is the classification the audit turned on. hardware means the machine chooses — PCI functions, CPUs, ACPI rows, memory-map entries. external means a file, disk structure or peer chooses. ours means we do: a stack size, a tick rate, our own protocol's payload. A fixed bound on the first two is a defect rather than a tunable.

What the build step enforces

A step in build.zig reads the tree and fails on:

  1. A bound with no declaration. A fixed-size array or a maximum_*/max_* constant with no bound: block above it. The 235 that exist today are allowlisted by file+line+name, so only newly written ones are gated — the rule can land without a 235-site sweep in front of it.
  2. A missing field. All five or it fails. This alone is the 97 bounds with no comment at all and the 171 with no observability.
  3. An unspeakable at-limit. The vocabulary is closed. There is no silent, no drop, and nothing meaning allow. truncate is legal only with a marker the reader can see — klog_maximum_message qualifies because the record carries klog_flag_truncated; the USB configuration descriptor cut at 512 bytes does not, because nothing records that anything was lost.
  4. A stale allowlist entry. If a listed bound is fixed or deleted, its entry goes too, so the list can only shrink.

Because this is text and not a Zig type, it also covers boot/, which imports almost nothing, and tools/*.py, where the audit found bounds as well. One mechanism, whole tree, no new dependency edges.

Coupled bounds

Two numbers that must agree, agreeing in code rather than in a comment — no module needed, just a comptime block where one of them lives:

comptime {
    if (maximum_domains != devices_broker.maximum_devices)
        @compileError("iommu.confined is indexed by device id; an id past its end is " ++
            "left unconfined while confineDevice still reports success");
}

maximum_domains = 64 and maximum_devices = 64 agree today only by a sentence in a comment, and the agreement fails open. This is the clause with a live hole behind it, and the reason raising maximum_devices alone would be a privilege escalation rather than a fix.

The worked bad case

devices_broker.maximum_devices, which had no comment at all:

/// bound: device nodes for the whole machine — firmware-discovered plus registered
/// decided-by: hardware
/// protects: nothing; this is a sizing guess about someone else's computer
/// at-limit: refuse — ENOSPC from device_register, dropped++ during discovery
/// observed-by: kernel.zig:203 counts discovery drops only, NOT runtime refusals
const maximum_devices = 64;

Writing it out is the argument. decided-by: hardware alongside a protects that admits there is no threat describes a bound that should not be fixed at all, and observed-by cannot be filled in honestly. The build step does not reject this — it makes it impossible to write down without noticing.

The work-list this produces

decided-by is machine-readable, so the sweep is a query: every hardware or external bound whose at-limit is not grow. That is 139 of the 235, and it is the order the fixing takes — by class, not by our guesses about which machines get run. Reachability is exactly what a new computer changes; the Ryzen's cap was unreachable until it wasn't.

What this does not do

It is a statement, not a proof. Nothing checks that the code does what at-limit claims. The enforcement is completeness and vocabulary: you cannot leave the question unanswered, and you cannot answer it with "silently".

It carries no occupancy. Knowing system/configuration/protocol.csv sits at 50 of 64 grant rows still needs a counter and somewhere to report it. The declarations make that cheap to add later; it is not here.

It resizes nothing. Declaring maximum_devices honestly does not make the Ryzen work. It makes the next machine's failure legible, and it names the 139 that need real fixes.

Enforcement is at build time, not compile time. A Zig type could have made a missing field a compile error. That version needed the shared module, and the module was not worth the coupling — so a missing field is a failed build step instead. In practice both mean zig build stops; the difference is which stage prints the message.