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

141 lines
6.8 KiB
Markdown

# Bounds: how a ceiling is declared
*Design, 2026-08-08. Follows [fixed-bounds-audit.md](../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:
```zig
/// 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:
```zig
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:
```zig
/// 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.