C4 - one operation vocabulary, and the Linux ABI leaves arch_hw.c
Status, 2026-09-21: stages 1, 3, 2a and 2b done. The plan
(docs/core/phases.md) is the authority on intent; this file records what was
built, what it measured, and what it found on the way.
Stage 1: the vocabulary, its declarations, and their enforcement
include/vibeos/abi.hdeclares every kernel operation once, with the checks that apply to it, in an X-macro list. The enum, the name table and the check table cannot drift, and a line without its checks does not compile. It is namedop, notsyscall: the dispatcher C3 deleted had left its own vocabulary behind (syscall.h,syscall_abi.h,syscall_policy.h, 1,023 lines nothing used), which was removed on 2026-09-20 - one vocabulary now.kernel/abi/abi_linux.cis the Linux ABI as a translator: a constantnumber -> operationtable plusclone()'s decision by flags (which is whyclassifytakes the first argument). The ABI is bound to a task inhw_task_alloc, the one place a slot is claimed, and read from the task.scripts/dev/check-syscall-checks.pyholds each declaration against the handlers' call graph, in both directions. It is reachability, not proof: it catches a check that disappears, not a check applied to the wrong pointer. Its first run corrected three of my own declarations (exit,exit_group,prctltouch user memory).- Found and removed on the way:
vibeos_linux_translate_syscallwas called on every syscall and its result stored in a variable nothing read, and a[COMPAT] translated=counter printed a number that meant nothing.
Stage 3: 4,000 lines leave arch_hw.c
arch_hw.c went from 12,174 to about 7,800 lines. Its Linux-facing handlers and
the helpers only they use now live in kernel/abi/linux/:
| file | what |
|---|---|
dispatch.c | classify, then switch on the operation |
fs.c | open, read, write, pipes, stat, getdents, ioctl, the console |
proc.c | fork, clone, execve, wait, exit_group, prctl, futex, prlimit |
mm.c | brk, mmap, mprotect, munmap, pageinfo |
sig.c, signal.c | kill/tkill/tgkill/sigaction/sigprocmask; frame building and sigreturn |
net.c | the socket calls (was arch/x86_64/linux_socket.c) |
misc.c | uname, clock_gettime, time, sysinfo (added for the reclaim load) |
common.c, linux_internal.h | what several of the above share |
How it was cut. Not by line range: the stretch was interleaved with
architecture that has nothing to do with Linux - the copy-on-write fault handler,
the TLB shootdown, the crash dumper, task lookup. A throwaway tool parsed the file
into top-level items, built the reference graph, and moved a handler together with
every helper only handlers reference (an item moves iff everything that
references it moves). Everything else stayed and became a named dependency in
arch_hw_internal.h - 67 declarations and 29 macros and types, which is the honest
size of the seam. Being driven by the compiler, the first pass turned up four
mistakes in the tool itself (types named by shared declarations, static inline
helpers, prototypes of moved symbols, and section banners that travelled with the
first item after them); all four are fixed and none is in the product.
Two metrics moved, both honestly.
- input device went 4 to 5 the moment the handlers moved, because
fs.cnamed the keyboard and framebuffer directly - a dependency that had been invisible inside one file. It is 4 again: the console is now two functions the architecture supplies (hw_console_getc,hw_console_echo). - syscall is 6 (1 before C4, 4 after stage 1). The case and the handler are now two files and the handler needs a declaration between them. That is the price of the cut and it is meant to be temporary; stage 2 exists to remove it.
The check this phase added, and what it found
scripts/dev/check-sabotage-anchors.py verifies that every sabotage case still has
an anchor to break. Moving 4,000 lines makes stale anchors certain, and
sabotage.py only reports one when somebody runs it. First run: 20 cases with
nothing left to break. Four were this phase's (two anchors that moved, two written
against names renamed in the same change and never run - now run and red).
Sixteen were stale before C4 began - and are repaired: mm-vmspace (seven), threads (two),
tlb-shootdown, stress, journal, ahci, ring3-fault, services,
storage. Each was re-run, which found three that mattered more than their anchors (a shootdown counter that stopped isolating fork, an untested stale-TLB guard, an AHCI sabotage OVMF had already defeated); see check-sabotage-anchors.py. Unresolved is now ratcheted at zero.
Not done
- The linkage from
linux_internal.htoarch_hw_internal.his a relative include. The Linux layer still reaches x86-64 objects (a task, its address space, its descriptor table) directly; abstracting that is the day a second architecture exists, not before. (Done earlier than that, on 2026-09-29, for a different reason: host tests and a second personality. The include is gone; see docs/abi/ phase A2 andinclude/vibeos/ksvc.h.)
Stage 2b: the pointer arguments are declared in the row (2026-09-21)
- A row's fourth column is
NOPTRorPTRS(...): which arguments are user pointers, how long, read or write, optional, and - for a request code that decides whether the argument is a pointer at all (ioctl, prctl, arch_prctl, netctl) - the value of the argument it depends on.vibeos_ptr_t(abi.h) holds it; the constructors (OUT,IN,OUT_BUF,IN_VEC,*_OPT,*_IF) are inlinux_internal.h. - The dispatcher's
check_pointersvalidates them before the handler runs and answers -EFAULT. 22 rows carry descriptors and about 30 handler checks are gone. The registry refuses a descriptor that names an argument the call does not have. hw_user_range_okhas one call site (linux_user_ok, dispatch.c); it was 46.check-chokepoints.pyreports 4 for it (definition, two declarations, that call) and watcheslinux_user_okat 15, where the old "a syscall stopped checking" alarm lives now.- What stays in the handlers (12 sites), and why each is not a descriptor. Each was
weighed for moving; futex was the one that could, and did (a descriptor can carry its
own error and a mask on the argument that selects it). The rest:
- an iovec element's own base (2): the descriptor would validate the array up front and the handler would read it again later; a sibling thread can change or unmap it in between (H-020). The check has to be next to the use, so it stays.
- readlink's length (1): it is min(buffer, link target), known only after the lookup. Declaring the whole buffer would answer EFAULT to a call Linux allows.
- clone's stack (1): it is thread-creation policy with a diagnostic line, at an
offset from the argument (
child_stack - 8), only when the flags ask for a thread. - clone's two tid words and wait's status word (3): optional stores that are skipped silently when invalid, not refused. A descriptor refuses; these must not.
- the signal frame and sigreturn (2): a stack pointer taken from the trapframe, not from an argument.
- the kernel's own reads (3): a user string, the crash dump's two probes. No syscall names them.
- Error precedence changed, deliberately, and this is the price. A call that has a bad pointer and another error now reports EFAULT where the handler used to report the other one first: read on a bad fd with a bad buffer (was EBADF), getdents on a non-directory (was ENOTDIR), getcwd with size 1 (was ERANGE), pipe2 with a bad pointer and bad flags, sigaction with a bad signal number and a bad pointer, the ioctl cases for descriptors above 2. Linux itself mostly reports EBADF first; no program here depends on the order and the boot's ABI self-test passes, but it is a behaviour change and is recorded as one.
- Decision (2026-09-21): EFAULT-first stays; Linux's EBADF-first is not reproduced.
Doing it would make the engine look up file descriptors - a filesystem dependency
(the console's fd < 3, sockets) inside the one generic place - and would make every
future row declare which check precedes its pointer. That is permanent surface for
an order nothing here depends on. It is reversible: the choice lives only in
check_pointersand in this note. Revisit if a real program relies on the order. - The engine is gated for the first time. The ring-3 program writes from a kernel
address and prints a line only if it was refused; nothing asserted that line until
now, so an engine that validated nothing would have booted green. The gate has
kernel_pointer_not_rejected, andcases/core-ptr-engine.txtshows both engine sabotages turn the boot red with that reason. - Sabotage:
core-ptr-engine.txt(2, boot),core-ptr-descriptors.txt(4),core-syscall-hoist.txt(3),core-user-check.txt(2, chokepoints).
Stage 2a: rows registered by the file that holds the handler (2026-09-21)
- Each of fs/proc/mm/sig/misc/net.c ends with an X-macro list of rows (number, name,
operation, call) and
LINUX_DEFINE_SYSCALLS, which generates the adapters and the table.dispatch.clooks the number up in the registry; the centralswitchis gone. - The registry (
abi_linux.c) validates a whole table before taking it and refuses a duplicate number, a NULL handler and an operation NONE or out of range. - "Add a syscall" is 2 files (abi.h and the file that holds the handler), from 6.
The number is stated a second time in
scripts/dev/linux-syscall-numbers.txton purpose, andcheck-syscall-checks.pyholds every row against it. (Since 2026-09-29 that second statement is the registry of every Linux number,kernel/abi/linux_syscalls.def- docs/abi/ phase A1 - and the file is gone.) - Sabotage:
cases/core-syscall-rows.txt(wrong number, duplicate number, deleted row, row that no longer validates memory) and four registry cases inabi-abi_linux.txt; all red. Full check green, 3/3 boots, clang and gcc warnings 0.