Files
danos/docs/os-development/bounds.md
T
Daniel Samson bf0595763e docs: flip stale status markers across the tracks (audit found 23)
An all-tracks docs-vs-code audit (the same method that caught the storage
drift) found 23 confirmed inaccuracies where a doc's build-status claim no
longer matches the source — status markers that were never flipped after a
track landed, and a few paths left over from completed flag-days. All verified
against the code before editing; docs only, no behavior change.

The systemic ones:
- IOMMU enforcement (driver-model.md, drivers.md): docs said enforcement was
  not built and "device_claim = ring 0" / "memory-safe is not true yet". It is
  built (per-device VT-d/AMD-Vi domains programmed at device_claim, -ECONFINE
  rollback, dma_alloc buffers bound and torn down at death; fail-open only with
  no IOMMU). Restated; M16 marker flipped to done.
- The FHS flag-day paths: /etc/devices.csv -> /system/configuration/devices.csv
  (devices-csv.md, new-driver-checklist.md, device-manager.md), /var/log ->
  /system/logs (logging.md, new-driver-checklist.md), /mnt/usb -> /volumes/usb
  (process-management.md). Following the old paths silently breaks driver match.
- protocol-namespace P4 "remaining" -> landed (only P5 remains); shared-fate
  fan-out "not yet enforced" -> enforced; wall_clock "not built" -> built;
  SMP affinity + fault-recovery "left" -> built; process_enumerate raw-pointer
  trust model -> checked copyToUser/EFAULT; bounds.md maximum_devices static
  hole -> dynamic per-registrar quota; init spawns fat -> volume-manager;
  config "hardcoded, move to /etc" -> already CSV data files; vdso.md three-
  value call; zig-self-hosting library/ layout; python argv "new" -> built.

Found and fixed by a multi-agent audit across 12 doc clusters, each finding
adversarially verified against the source.
2026-08-09 22:04:17 +01:00

7.3 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 once agreed only by a sentence in a comment, and that agreement failed open — the clause with the live hole behind it, where raising maximum_devices alone left every device id past the end of iommu.confined unconfined while confineDevice still reported success, a privilege escalation rather than a fix. The assert closed that: it held the two together while both stayed fixed, and when the device table was later made dynamic — no maximum_devices any more, only a per-registrar quota — that forced them apart, the assert having done its job. confined now grows to cover every id the broker mints, and confineDevice refuses when it cannot record a confinement rather than failing open.

The worked bad case

devices_broker.maximum_devices, which had no comment at all, before it was made dynamic:

/// 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.