C7 - the registries the next refactor needs
Status, 2026-09-23: done - the registry, proved on the input devices; the network interface; the disk drivers and the registered filesystem; the display. The "done when" - the GUI registered with its own lock, counters, a must-be-zero and a case file - is met. The blast-radius objective is met for every device row and not for two others; see the table at the end.
The plan (docs/core/phases.md): character, input, network and display devices
register the way block devices do, check-blast-radius.py reports 1 for
every row, and the GUI is registered rather than wired in, with its own lock,
counters, a must-be-zero and a case file.
Where it started
check-blast-radius.py measured every driver kind at 3 or 4: adding one meant
editing, besides its own file and its line in the build,
- an init call in
arch_hw.c(orio_bringup.c), - a declaration in
arch_x86_64.h, - a branch in the interrupt handler naming its vector,
- and a print of its counters in
kmain.c, with a weak default beside it.
Block devices had a registry (vibeos_blk_register) and it bought nothing
measurable: everything around it named each driver. include/vibeos/drivers.h
had a portable "driver framework" that no running code consulted.
Step 1: the registry, proved on the input devices
include/vibeos/device.h, kernel/io/device.c.
- A driver is a descriptor in its own file: name, class, legacy interrupt
line,
probe,irq,selftest,report, and its class's operations.VIBEOS_DEVICE(desc)places a pointer to it in thevibeos_deviceslinker section;kernel.ldcollects the section between two symbols andarch_hw.chands the table to the registry. Nothing names a driver. - The registry does what
arch_hw.candkmain.cdid by name: routes every declared legacy line (before any probe - a probe raises its line, and the mouse's acknowledgements were exactly that), probes, dispatches an interrupt to every device registered on its line (present or not - a PS/2 mouse that failed its probe still has to take its byte off the port), runs self-tests and prints each driver's counters on a line of its own, and serves the input class:vibeos_input_getc,vibeos_input_inject,vibeos_input_pointer. The console reads a character and the display reads the pointer without knowing either is PS/2. - Set once, probed once, then sealed. The readers - interrupt dispatch among
them - take no lock, because the data no longer changes; a second table or a
second probe is refused and counted (
VIBEOS_MBZ_DEVICE_AFTER_SEAL). The report takes the module's own lock for the self-tests. - Probes run outside the lock. The lock masks interrupts, and the keyboard's probe waits for one (below). A host test fails if a probe ever sees the lock held.
The keyboard and the mouse moved onto it. Every symbol either exported is gone;
the keyboard's counters left the middle of a kmain.c line for a [KBD] line of
their own, the mouse's left arch_hw.c for a [MOUSE] line from its driver, and
the mouse's packets counter - counted since the driver was written and read by
nobody - is on that line now.
Input device: 4 -> 1. The one edit left is the file's line in the build.
What building it found
Nobody types in CI, so the keyboard's interrupt path had never run in a boot. Two boot sabotages - do not route the legacy lines; do not wake a reader when an input interrupt arrives - went NOT RED. The reason was not a weak assertion: the boot's script is injected into the keyboard's ring, which raises no interrupt, so IRQ1's routing, the dispatch and the wake-up were exercised by no boot at all. The first had also been true before C7, under the name of the driver.
The keyboard's probe now proves the path: it asks the PS/2 controller to put a
byte in its output buffer as though the keyboard had sent it (command 0xD2,
with a key-release code the driver ignores), which raises IRQ1 exactly as a
keystroke does, and waits - bounded - for it. [KBD] ... irq_proved= says it
arrived; [DEV] ... input_irq_wakes= says the dispatch woke readers because of
it. The gate asserts both (kbd_irq_unproven, input_irq_wake_unproven), and the
two sabotages are red by those names now. If the interrupt never comes, the probe
takes the byte back off the port - otherwise the mouse's probe, next, would read a
keyboard byte as its own acknowledgement.
The first version probed under the lock, where that interrupt could never
arrive on the core that was waiting for it: the lock masks interrupts, and the
line is routed to that core. Caught by reading the code before the boot; the
host test and io-device.txt hold it now.
KEEP in the linker script does nothing today - the image is not linked with
--gc-sections - and the first draft of its comment said otherwise. It is there
for the day it is; [DEV] registered= is asserted (device_table_short), so a
section that loses its entries is named either way.
How it is proved
- Host test
tests/kernel/device_tests.c: refusal of a table with a hole or too many entries; presence before and after probing; every declared line routed; probes in order, outside the lock, seeing the machine's description; the seal and its must-be-zero; interrupt dispatch to every registered device on a line, and to nobody on an undeclared one; self-tests under the lock and reports outside it, for absent devices too; the input class in table order, the pointer only from a present device; report lines whole and newline-terminated. scripts/dev/cases/io-device.txt(13 cases, host; 9-10 from step 2, 11-13 from step 3a): all red by name.scripts/dev/cases/io-device-boot.txt(4 cases, boot; the fourth from step 3a): an empty table (verdict=boot_volume_missing: no disk driver, so no volume; it was a "wedge" until the kernel said so); no wake; no routing.core-observability-modules.txt: the three mouse cases, re-pointed at the driver, red by name.
Step 2: the network interface
A NET class (vibeos_net_ops_t: mac, send, recv) and
vibeos_net_device(), which hands the stack the first present network device
with every operation - not one whose probe failed (no queues: every frame
swallowed) and not one without recv. virtio-net's init is its probe, and
hw_net_bringup asks the registry for the result instead of naming the driver.
Network interface: 4 -> 1. What it had been: arch_hw.c declared six of the
driver's functions and called four; kmain.c printed its timeout count through a
weak default; and two accessors - the transmit/receive frame counts and ready -
were declared and called by nobody. The frame counts are on the driver's own
[VNET] line now (a boot sends about ten and receives about nine), next to
net_tx_timeouts, which left kmain's [IO] WAITS line; the gate reads each
where it is and still calls either one missing io_wait_counters_missing.
Proved by the host test (a failed or incomplete network device is never handed
out; io-device.txt cases 9-10) and on the boot (io-device-net.txt: the
timeout leaving the report line is io_wait_counters_missing; a device reported
absent takes the lease and the TCP round trip down with it, by name).
Step 3a: the disk drivers
A BLOCK class (vibeos_block_ops_t: read, read_many, write,
write_many, barrier, sectors, timeouts). AHCI and virtio-blk are
descriptors; their init is their probe, and arch_hw.c binds every present
block device in table order through one loop instead of initialising each by
name and spelling out seven of its functions.
Interrupt vectors are handed out, not chosen. A PCI device has no legacy
line to declare, so the descriptor's isa_irq is VIBEOS_DEVICE_NO_IRQ and
the registry gives each table slot a vector of its own - slot i gets
VIBEOS_DEVICE_VECTOR_BASE + i (48..63), passed to the probe in
env->vector, one stub each in isr.S. The disks used to route their lines to
42 and 43, literals chosen by hand inside the legacy range, each with its own
branch in the interrupt handler. vibeos_device_irq_vector dispatches to the
slot; anything it cannot place is stray_vectors on the [DEV] line, a
must-be-zero the gate asserts (device_stray_vector). Two rules in that
dispatch, each with a host test and a sabotage case:
- it does not require the device to be present: a disk's probe is its init, which routes the line and then issues commands that complete by interrupt - before the probe has returned and the slot is marked;
- a device on a legacy line is never dispatched a vector: it was handed one it does not use, and a stray would otherwise become a keyboard read of port 0x60.
The probes moved after the kernel log exists (the disk drivers log). The
counters left kmain's tlbq line - four weak defaults and four prints - for
each driver's own report line, [AHCI] ahci_irqs=.. timeouts=.. and
[VBLK] blk_irqs=.. blk_irq_completions=.. blk_poll_completions=.. timeouts=..;
the gate's patterns were never anchored to the line, so they read the new ones
unchanged. [DEV] registered= must now be at least 5 (keyboard, mouse,
virtio-net and both disks are always built).
Block driver: 4 -> 1. One visible change: the adapters now bind in link order,
AHCI first ([BLK] disks=.. disk=ahci:0 disk=virtio-blk:1). That was checked
rather than assumed: which disk the machine boots from has been decided by
mounting since I5, not by bind order, and the boot gate is green on both
controllers.
Proved by the host test (env.vector == BASE + slot; a vector reaches that slot
and no other; strays past the table, without a handler, on a legacy line and
either side of the range call nobody; io-device.txt cases 11-13) and on the
boot, every case red:
io-device-block.txt(AHCI, 3 cases): its line routed to a registry vector nobody was given isdevice_stray_vector- about 5.9 million of them, because a level-triggered line whose handler never runs is never acknowledged - andahci_interrupt_never_fired; the vector ignored (the driver polls, and the disk still works, which is exactly why only the counter can say so) isahci_interrupt_never_fired; the count leaving its line isahci_irq_counter_missing.io-device-block-vblk.txt(virtio-blk, 2 cases):blk_interrupt_never_firedandblk_completion_counters_missing.io-device-boot.txtcase 4, the bind loop stopping after the first disk:verdict=boot_volume_missing. It first went red as a wedge - see below.
"Wedged" was a machine that had finished
That case first went red as missing:VIBEOS_SELFTEST_DONE verdict=guest_wedged,
and so did the other two ways of losing the boot volume (case 1, an empty
device table; io-device-fs.txt case 1, a FAT driver that does not declare
itself). The serial log says otherwise. With only AHCI bound the kernel printed
[BLK] boot volume on none, ran the built-in init, finished userland at once and
sat at vibeos>. Nothing was hung. The gate was waiting for a self-test that
lives on the disk that was not there, and after 45 seconds of an idle prompt it
called the quiet a wedge and sent wedge_report to look for a hung core.
Carrying on without a volume is kept on purpose: the built-in init and the
kernel console are worth having on a machine whose disk this kernel cannot read,
which is the VirtualBox story in CLAUDE.md. What changed is that the kernel says
so as a stage failure,
[BLK] BOOT_VOLUME_FAIL: no disk carries a mountable volume tried=.., and the
gate turns that into its own verdict, boot_volume_missing, ahead of
guest_wedged and without the wedge report. All three cases now go red with
that verdict. The same sabotage without the marker is guest_wedged, which is
the before.
tried, not the existing rejected: rejected counts adapters passed over on
the way to one that mounted, so when none mounts it is one short - the failing
boot printed rejected=0x0 with one disk tried.
Step 3b: the registered filesystem, without its init call
FAT had a registry - vibeos_storage_register - and still cost four files:
io_bringup.c called a register function by name, arch_x86_64.h declared it
beside an ops accessor and two exported function pointers (the probe and the
formatter, for the I4c exercise), and arch_hw.c declared the boot-volume mount
extern. Now the driver declares itself with VIBEOS_FS_DRIVER into a linker
section of its own; arch_hw.c registers whatever is there, once, at boot,
before anything is mounted; and every caller reaches it through the table -
vibeos_storage_driver(name) for the two that choose a filesystem rather than
scan for one (the boot volume, which UEFI requires to be FAT, and the exercise
that formats one), the scan for everything else. The storage core still takes
registrations rather than walking the section, because PE/COFF has no
__start_/__stop_ symbols and the Windows job builds that core.
Filesystem (registered): 4 -> 1. "Filesystem (direct)" - ext2, NTFS, exFAT
and ISO9660, compiled into storage.c's probe table - is still 4.
Proved by a host test the registry never had (test_storage_driver_registry:
whole-name lookup, a driver with no mount refused, reset) with two cases in
storage.txt, and on the boot by io-device-fs.txt: the driver not declaring
itself is verdict=boot_volume_missing (it was a "wedge"; see step 3a); a
mount that ignores the volume it is given is
red by name, format:FAILED:_also_on_the_root - after two fixes, below.
What the sabotage found: a check that had been blind for seventeen days
The second case went NOT RED, and the reason was two defects stacked.
- The exercise could not see it. It formats a scratch volume, mounts it at
/vol1, writes a file and reads it back through the resolver - which proves/vol1reaches the scratch mount and that the mount round-trips. A mount of the boot volume does both, so the file went onto the disk the machine runs from and came back intact. It now also checks that the file is not visible at/, and saysFAILED: also on the root. - The gate could not see that. Instrumented, the sabotaged boot printed
result=FAILED: also on the rootand the gate still passed: the assertion was"result=OK" not in text, anywhere in the log, and[IO] LOGSINK result=OKis on every boot. Both lines date from 2026-09-06 - the FORMAT check in I4c step 3, the log sink in I5b later the same day - so from then on no FORMAT failure could be reported. The gate reads the FORMAT line's own verdict now. The other two whole-log..=OKtokens the gate uses (round_trip=OK,tls=ok) were checked against a log and appear on one line each.
Step 4: the display - the phase's "done when"
Before C7 the GUI was wired in: arch_hw.c declared five of its functions,
initialised it from the framebuffer setup, repainted it from the timer, printed
its counters and kept its canary, and serial.c named its putc through a weak
default. It had no lock, and two of its three callers run on any core: the
timer's repaint, and putc from the console write path - which is not always
under the console lock (kmain's hex printer writes a character at a time with
none). Two cores in putc could both pass row < ROWS before either scrolled,
and the second wrote a row past the end of the grid, into whatever the linker
put after it. The phase calls it the prime suspect for corrupting another
process's memory; whether it ever did is not known, and now cannot happen
silently.
It is kernel/io/gui.c now, a DISPLAY-class device, with the seven parts of
docs/core/architecture.md:
- A header,
include/vibeos/gui.h. - All of its state in one file, in two kinds with one rule each: the
terminal grid and the counters under the lock, every access; the back
buffer, the screen and the pointer's last position owned by whoever holds
g_ticking, which only the repaint takes, by exchange. The repaint copies the grid under the lock and draws from the copy outside it, so a console write never waits for a window to be blitted with interrupts off. - Its own lock, a
vibeos_dev_lock_t. The lock operations are registered once, by the machine, for every driver (vibeos_device_set_lock_ops): a portable driver cannot build a lock, and registering each driver's lock by name from the arch would have put the driver back inarch_hw.c. The provider has one extra duty:lockreturns -1 instead of waiting when the caller already holds it - the machine answers from the spinlock's owning CPU - so a panic printing from inside the GUI is dropped and counted (reentered) instead of waiting on its own core. - Counters with two must-be-zeros:
term_overrun(a grid write out of bounds, refused) andguard_broken(the canary). The canary is the GUI's own now, written directly after the pixels and examined on every repaint, not once at the end of the boot;guard_checkssays it ran. - An init that refuses by name: no framebuffer; too large; too small; a back buffer with no room for the canary; already active.
- The registry's DISPLAY class: the console writes through
vibeos_display_putcand the timer repaints throughvibeos_display_tick; neither names a driver. - Case files:
io-gui.txt(host),io-gui-torture.txt(the model and threads),io-gui-boot.txtandio-gui-backbuf.txt(the gate).
The font became include/vibeos/font8x8.h, a header: a constant table has no
state, and as a .c module it would be judged by check-subsystem for a lock
and a must-be-zero it has no use for.
Display: 3 -> 1. Nothing outside gui.c and its header names the GUI.
What building it found
- A screen under about 30 pixels high wrote off the end of the buffer. The
text area's height is
window - 26; on a 30-pixel screen that is25 - 26, which is 0xFFFFFFFF, and the clampif (y + h > g_h)wrappedy + hback under the screen - so the fill ran on. Width had the same shape below 10 pixels. No real screen is that small;initaccepted any. It refuses them by name now, and every clamp compares before it adds. - The character that wrapped the last row was lost. The old putc wrapped, found the row past the end, skipped the write and scrolled afterwards.
- A machine with no mouse never showed console text: the repaint returned before the text when there was no pointer.
- The pointer left fragments on the desktop. After a text repaint the old code forgot the pointer's position ("whatever was under the pointer is gone"), which is true only inside the text window. The torture's first run found it, comparing the screen with the composition pixel by pixel; its sabotage case puts the old line back.
- A registered driver in a static archive is not linked. Moving
gui.ctokernel/ioput it in the core library the image links as an archive, and an archive member is extracted only when something references it - which, for a driver that registers, nothing does. The boot came up withregistered=0x5, the text console took the framebuffer, and the gate saidgui_counters_missing. Such drivers are listed inVIBEOS_KERNEL_DRIVER_SOURCES, compiled into the image directly and into the host library for the tests; the gate's floor is six devices. - A GUI that refused passed the gate. Every GUI check was conditional on
[GUI] desktop up. The gate now failsgui_refused:<reason>when the text console had to take the framebuffer. [FB] no framebuffer; console is serial-onlywas printed on every boot that had a desktop - the text console's "else" was reached whenever the GUI had taken the screen. Three cases, three lines.backbuf_sharedandbackbuf_lostwere must-be-zero and asserted by nobody.guard_brokenled their line andcheck-mustbezero-assertedreads only the first name after the word; moving the canary exposed them.
How it is proved
tests/kernel/gui_tests.c, in the host suite: the refusals by name with nothing written, the canary written and checked on the repaint and counted once, the terminal's rows, wrap and scroll, the wrap on the last row, the pointer drawn and not baked into the composition, reentrancy, the lock paired, and the display class choosing the first present display with both operations.tests/kernel/gui_torture.c(vibeos_gui_torture <seed> [rounds] [threads]): geometry at the edges of what init accepts, with both buffers fenced; the terminal against a model that keeps a list of lines rather than a grid; the screen against the composition, and the pointer visible; a flipped canary word; a print from inside the lock; then writers and two racing repaints on real threads, with every character accounted for. A short run incheck.sh; the nightly jobgui-tortureruns 200 seeds under ASan and UBSan and the threads under ThreadSanitizer.- The gate:
gui_counters_missing,gui_term_overrun,gui_guard_never_checked,gui_refused,gui_backbuf_shared,gui_backbuf_lost, anddevice_table_shortat six.
Every case red, and all but one by name:
io-gui.txt(6, host): each by the test that names it. Removing the minimum screen accepted a 30-pixel screen and did not overrun: the clamps now compare before they add, so the two defences each hold alone.io-gui-torture.txt(4): no lock at all - the GUI as it was - is71094 characters sent, 37897 countedfrom the thread phase; the pointer fragment is "screen differs from the composition"; a pointer not redrawn is "not on the screen"; an uncounted scroll disagrees with the model. The first version of the no-lock case removed the lock and kept the unlock, and went red on the single-threaded pairing check instead - which proved the wrong thing, so it was rewritten.io-gui-boot.txt(6) andio-gui-backbuf.txt(2):gui_counters_missing,gui_guard_never_checked,gui_term_overrun=483,gui_refused:back_buffer_missing_or_too_small_for_the_screen_and_its_canary,gui_reported_nothing,gui_terminal_empty,gui_backbuf_shared=1000,gui_backbuf_lost=1000.io-device.txtcase 14, a display with no repaint handed out: red by name. Its first run was red as a crash - the test called the repaint before checking which display it had - and the test was reordered.
What CI found on the first push
The Windows job built the first VIBEOS_DEVICE descriptor in the portable library
without complaint, and the nightly's new job - 200 seeds under ASan and UBSan,
the threads under ThreadSanitizer - passed. The clang Debug job did not:
UBSan stopped on store to misaligned address ... uint64_t in the canary. The
canary sat straight after the pixels, and a screen with an odd pixel count puts
that on a 4-byte boundary. gcc's UBSan, in the nightly, ran the same code and
said nothing; one sanitizer's silence is not the other's.
It starts at the pixel count rounded up to even now, the size the caller owes is
one macro (VIBEOS_GUI_BACK_BYTES), and init refuses a buffer that is not
8-byte aligned. The host test's screen is 201x151, so the odd case runs on every
build rather than only on the torture's random screens. io-gui-ubsan.txt puts
the old placement back and verify-host-sanitized.sh - the CI job's own
configuration - reports the same error CI did (M-053).
check.sh all green, warnings 0 on gcc and clang, repeat-boot 6/6.
Where C7 leaves the rows
| Extension point | Before C7 | Now |
|---|---|---|
| input device | 4 | 1 |
| network interface | 4 | 1 |
| block driver | 4 | 1 |
| filesystem (registered) | 4 | 1 |
| display | 3 | 1 |
| filesystem (direct) | 4 | 1 (2026-09-27) |
| syscall | 2 | 2 |
The objective said every row. One is not 1, by a recorded decision: a syscall
is 2 (its row, and its declaration in abi.h). The other - the four
filesystems compiled into storage.c - was closed afterwards; see the last
section.
The last row: the four filesystems (2026-09-27)
ext2, NTFS, exFAT and ISO9660 declare themselves with VIBEOS_FS_DRIVER, as
FAT did at step 3b, and nothing names them. They were named in four files:
storage.c's probe table with a mount wrapper each, a member each in
vibeos_volume_t, and io_bringup.c, which mounted the loop-device images
through each driver's state, mount and ops. Now:
- The volume supplies the state.
vibeos_volume_thas one opaquefs_state(VIBEOS_FS_STATE_BYTES, sized by NTFS's 4160) instead of a member per filesystem, and each driver asserts at compile time that its state fits. The caller keeping the storage is the block layer's existing rule - a driver with a pool of its own would have run out in the host tests, which scan more than once. - Each driver states its order. NTFS 10, exFAT 20, ext2 30, ISO9660 40, FAT 50. The linker section they arrive in has no order, and the order is a correctness decision: NTFS and exFAT boot sectors are FAT boot sectors, and FAT's probe would claim them. Registration inserts in order.
- The volume's length reaches the driver, as
mount'ssectors- the authoritative bound of H-029, which the wrappers used to set on each driver's struct. - A probe is optional. These four refuse through their mount; a refusing mount is "not mine", not "mine and broken".
- They moved from the core archive to
VIBEOS_KERNEL_DRIVER_SOURCES. A registered driver in an archive is linked only if something references it, and nothing does - the GUI's lesson, again.
Two things went wrong on the way and both are cases now: registration still refused a driver without a probe, so all four were refused (on a boot, a panic at registration); and the host tests had to register the four themselves, because they do not walk the linker section. The scan zeroes the shared state before each attempt, because a refused mount leaves its fields behind.
check-blast-radius.py: filesystem (direct) 4 -> 1, total 11 -> 8. Cases
fs-registry.txt (host) and fs-ext2-registers.txt (boot).
Noticed and left: the boot gate asserts the loop-device images of ext2 and ISO9660 by name, not NTFS's or exFAT's. They are mounted and reported every boot; nothing fails if they stop working.