Invariants

Properties that must hold across every phase, each with the counter or assertion that watches it. A property with no watcher is a hope, and this project has a rule about those.

Most are not new. What this file does is state which of them the refactor is most likely to break, so a phase that breaks one is caught by a number rather than by a reader — which is the difference between ninety seconds and a phase.

Two of them are new in this revision and are the ones the refactor adds risk to rather than inherits: blast radius and the hot paths.


There is one definition of each thing

The invariant. For any question the kernel answers — what state a task is in, whether a signal may be sent, how big a time slice is — exactly one piece of code answers it.

Why it is first. The refactor's whole failure mode is producing a second answer that agrees today and drifts tomorrow. It has already happened here without a refactor: a portable syscall dispatcher nothing reaches, a page cache with no caller, a swap stack in the same state. The scheduler was on that list and has come off it — hw_pick_next now consults the portable policy — which is the one worked example of this invariant being restored.

Watched by. check-reachable.py — is this function named outside the file that defines it? — and check-counters-produced.py, which fails when a counter is declared and never written. A phase that moves a decision moves its counter, and a counter that stops being produced is the alarm.

Not yet watched: nothing detects a duplicated decision, only an unreached one. C4's enumeration test is the first thing in this plan that would.


A check has one site

The invariant. hw_signal_permitted, the fork guard, and the user-pointer range checks are called from every path that needs them and defined once.

Why it is here. These are choke points because they live in one file. Moving handlers out of arch_hw.c — which is exactly what C4 does, with 4,169 lines of them — is the operation that turns one choke point into two, and the second one always forgets a case. Every security-relevant defect review has found in this project was in that file, which is a consequence of its size rather than a coincidence.

Watched by. check-chokepoints.py from C1, and C4's enumeration test: every syscall declares which checks apply, and the test asserts the declared set is the set actually run.


Adding one thing touches one file

The invariant. For each extension point — filesystem, block driver, character device, input device, network interface, display, syscall, ABI — a new implementation is added by registering a struct, and the count of files edited does not grow.

Why it is new, and why it is the plan's real metric. Line count has been the stated completion criterion twice and failed twice: both times the file grew while the document naming its length sat unchanged. Blast radius is the property that was actually wanted, and unlike a line count it cannot be satisfied by moving code.

What it measures today, which is not what this file first claimed. This invariant was written saying the radius "is already 1 for storage and block". The first run of the check that enforces it said 4, 4, 4, 4, 3, 2 — nothing is 1, the floor is 2, and the registered filesystem costs exactly as much as the four that are hardcoded. The claim came from the existence of two registries rather than from a measurement, which is the failure mode this whole document is about, committed here.

So the invariant has a sharper form now. A seam whose members still need a bring-up call is not a seam. The target is an extension point where adding an implementation edits the source list and nothing else.

Watched by. check-blast-radius.py (C1), ratcheted at 21 across six points, sabotaged and confirmed red. The number may only go down; it goes down by moving an implementation onto a seam, never by editing the declaration.


The state machine is the only opinion about task state

The invariant. Every transition goes through hw_task_set_state, and the table refuses the illegal ones.

Why it survives the refactor unchanged. It has caught two real defects: a slot released outside the table stayed RUNNING for a boot, and a thread reaped straight to FREE logged one ILLEGAL line per thread. Both were invisible except through that table. C5 moves it; C5 must not dilute it.

Watched by. [TASKS] MUSTBEZERO illegal_transition, asserted zero.


Nothing is published before it is complete

The invariant. A task slot becomes FREE last; an address space is torn down before the zombie is announced; a kernel stack is freed by the core that has provably left it.

Why it is one property rather than three. They are three instances of the same mistake, found months apart, each diagnosed from the far end. The general form: a structure becomes visible to another core before it is finished, and the other core is faster than expected.

Watched by. use_after_publish, tenancy_mismatch, cr3_without_owner, dead_kstacks_freed, all on the [TASKS] lines, all asserted.


A frame is freed once, when nobody maps it

The invariant. owners equals the number of page-table entries pointing at a frame, and a frame returns to the allocator exactly when that reaches zero.

Why it is in a core plan. C5 moves the structure that owns address spaces, and this is the property most likely to break silently under that move. It is also the one currently violated — C0 exists for it.

Watched by. free_while_mapped, frames_double_put, poison_hits, fork_undercounted, rmap_mismatch — with a caveat that matters more than the list: the free-side check is sampled, historically one free in sixteen. A defect happening once a boot has a small chance of being seen. A zero from it is not evidence of absence, and any phase relying on these raises the sampling first and says what it raised it to.


Time is conserved

The invariant. charged + idle == seen, exactly, on every boot.

Why it is kept through C5. It is an identity rather than a tolerance, so it catches a scheduler that loses a tick as surely as one that loses a task — and C5 is precisely the phase that could lose a tick by charging it to the wrong owner during the handover.

Watched by. The [TASKS] CPUTIME line and its balanced=yes.


Every module says whether it is broken

The invariant. Every module has at least one must-be-zero counter, printed on its own tag and asserted by the gate.

Why it is new. There are five in the whole kernel today, against 25 report tags. Every hard defect in this project's history was found by a counter or a detector; a module with neither is a module whose defects will be found by reading, months late, at the cost this file exists to avoid.

Watched by. check-subsystem.py (C1), which fails a module that has none. Ratcheted, so today's gaps are the baseline and may only close.

And the rule attached to it: a counter nothing increments and a counter reporting nothing happening are indistinguishable. A new one needs one deliberate demonstration that it can be non-zero before anything is concluded from a zero.


A number the gate reads is produced by something

The invariant. Every counter the boot gate asserts is incremented somewhere.

Why it is stated rather than assumed. VIBEOS_BLK_TIMEOUT was defined, printed, asserted and produced by no driver — green by construction. rmap_cycles was in the same state. And a disk-interrupt counter read zero for an hour because the dispatcher hook that would have incremented it had never been added, which sent four measurements in the wrong direction.

Watched by. check-counters-produced.py, check-assertions-covered.py.


The hot paths do not get slower

The invariant. Syscall entry, page fault and context switch stay within a stated margin of the C0 baseline.

Why it is new. This kernel has never been measured. The architecture adds indirect calls to paths that run millions of times a boot, and with no baseline the refactor could not be shown not to have cost anything — in either direction, since an unmeasured reassurance is worth exactly as much as an unmeasured alarm.

The sub-rule that keeps it cheap: registration is resolved once, not per call. A driver is looked up at open, an ABI is bound at process creation, a lock is registered at init. The danger was never the indirect call; it is the lookup or the lock somebody adds beside it.

Watched by. The [PERF] line and its ratchet, confirmed red by slowing a hot path on purpose before it is trusted.


A detector is suspected before it is believed

The invariant. Not a counter — a rule about how the others are read, and it belongs here because every invariant above is enforced by an instrument that can itself be wrong.

Three detectors in one session reported defects that were their own bugs. A frame-poison check reported 3,019 use-after-frees in a boot that had none, and cost a phase. The scheduler torture reported nine seeds of unfair weighting that were its own model holding a stale nice.

How it is answered, since a count never answers it. Make the detector name the object: which frame, which word, what value, released by whom. The 3,019 became found=0 freed_by=0 at base + reserved_prefix — the pool's first frames, released by nobody — on the first boot after the witness was added.