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.
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:
- A bound with no declaration. A fixed-size array or a
maximum_*/max_*constant with nobound: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. - 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.
- An unspeakable
at-limit. The vocabulary is closed. There is nosilent, nodrop, and nothing meaning allow.truncateis legal only with a marker the reader can see —klog_maximum_messagequalifies because the record carriesklog_flag_truncated; the USB configuration descriptor cut at 512 bytes does not, because nothing records that anything was lost. - 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.