Kernel Core Progress

Status: In Progress (ring-3 runtime and shell baseline verified) Last review: 2026-08-01

Implemented

  • Boot stage orchestration in kernel/core/kmain.c.
  • Early bring-up sequence for core subsystems (PMM/VM/scheduler/IPC hooks).
  • Boot event signaling and serial boot markers (BOOT_OK) for smoke validation.
  • Boot-to-CLI baseline: after BOOT_OK, kernel emits CLI_READY, starts the serial prompt vibeos>, and handles help, status, echo <text>, halt, and reboot as bootstrap commands.
  • Kernel object containers wired in include/vibeos/kernel.h.
  • Security/policy subsystem initialization path integrated in core startup.
  • Host-safe x86_64 serial backend behavior: privileged port I/O is now gated by CPL detection to avoid user-mode SIGILL in CI host tests while preserving ring0 boot logging.
  • Stage health model in kernel/core/kmain.c with explicit boot-health bitset and fatal-failure classification (vibeos_kernel_boot_health).
  • Kernel observability baseline: fixed-size in-memory ring log (vibeos_log_t) records boot milestones and fatal boot failures without dynamic allocation.
  • Bootstrap CLI exposes log and extends status with log counters for QEMU/serial diagnostics.
  • Foundation fault-handling model: vibeos_kernel_dispatch_trap turns user-mode faults into non-fatal current-process termination and kernel-mode faults into fatal panic state, both recorded in the kernel ring log.
  • x86_64 runtime bring-up now owns paging, GDT/TSS, IDT, timer and syscall entry before starting ring-3 work in kernel/arch/x86_64/arch_hw.c.
  • A real user ELF can be loaded from the boot volume, forked/replaced, waited for and returned to the kernel without relying on the original kernel-space CLI path.
  • The runtime provides framebuffer and serial console paths; /BIN/SH supplies the interactive shell baseline.

Pending

  • Stronger failure-mode recovery between bring-up phases.
  • Runtime hardware page-fault entry wiring from the x86_64 IDT/assembly path into the kernel trap decision API.
  • More explicit stage rollback behavior for partial init failures.
  • Deeper boot-time configuration profile support (debug/perf/hardened) across runtime tunables.
  • A supervised, persistent user-space init/service-manager handoff; the current boot flow launches fixed demonstration and shell programs.
  • Crash-dump persistence, recovery policy and production panic containment.

Next checkpoint

  • Route the live runtime task/process state through the portable kernel core, rather than maintaining bring-up-specific runtime state in arch_hw.c.

Status history

Moved verbatim from the status cell of docs/implementation_progress.md on 2026-09-29, when that table was compacted to one or two sentences per area. The table says where things stand; this is how they got there.

Kernel Core (as of 2026-09-29)

C4 done (2026-09-21; stages 1, 2a, 2b, 3: rows in the file that implements them, pointer arguments declared in the row, hw_user_range_ok one call site). C5 done (2026-09-22): the task identity is a portable vibeos_task_t with one reset, descriptors a vibeos_fdtable_t, pipes a module with two must-be-zero counters (core_c5_pipes.md), task state owned by the task layer alone, and the task lifecycle (creating, describing, signalling, ending a task) moved out of arch_hw.c into task_life.c - arch_hw.c names a task's identity only in the five functions that switch context (check-task-identity.py; core_c5_task_state.md, core_c5_task_identity.md). C6 done (2026-09-22): the kernel log and the crash records are modules in kernel/diag with their own locks - neither ring had ever had one - and the UART and the disk are registered sinks; a line a sink refuses is a must-be-zero and a line the serial device drops while reporting success is a gap in its ln= numbers the boot gate names (core_c6_diag.md). C7 done (2026-09-23; the GUI is a registered display with its own lock, counters, two must-be-zeros, a canary checked on every repaint and four case files; display blast radius 3 -> 1; syscall row 2 by decision; filesystem (direct) 4 -> 1 on 2026-09-27, the four filesystems now declare themselves; on 2026-09-28 the memory bridge moved out of arch_hw.c to mm_bridge.c in two halves the context switch to task_switch.c, AP bring-up to smp.c and the userland start to userland.c, diagnostics to diagnostics.c (2026-09-29), the network bring-up to net_bringup.c, arch_hw.c 1,792 lines): drivers are descriptors in their own files, collected from a linker section; the device registry routes, probes, hands out interrupt vectors, dispatches interrupts, runs self-tests, prints counters and serves the input, network and block classes - keyboard, mouse, virtio-net, AHCI and virtio-blk moved, input device, network interface, block driver and registered filesystem blast radius 4 -> 1 each; the FORMAT exercise and its gate check turned out blind since 2026-09-06 and are fixed; a boot with no mountable volume says BOOT_VOLUME_FAIL and the gate names it boot_volume_missing instead of calling the idle prompt a wedge - and the keyboard probe now raises a real IRQ1, because no boot had ever exercised that path (core_c7_devices.md): the kernel has one operation vocabulary with each call's checks declared and held against the code (check-syscall-checks.py), the Linux ABI is a table-driven translator, and about 4,000 lines of handlers left arch_hw.c for kernel/abi/linux/. Stage 2 (the dispatcher runs the checks; handlers register rows) is next, and the "add a syscall" blast radius is a deliberate 6 until it lands. A new check found 16 sabotage cases that had no anchor left, older than this phase (core_c4_abi.md). Earlier: Dead-code removal started: kernel/mm/vm.c and kernel/core/interrupts.c are gone - 621 lines, their headers, three syscall handlers and their tests. They were not dead but built and never consulted: the live vibeos_kmain called vm_init, intc_init and intc_bind_timer_irq on every boot, and vibeos_intc_dispatch had no caller outside its own file, so the timer handler that bind registered could not fire. Two subsystems in the shape docs/core/ names as this project's most repeated defect. check-reachable.py never flagged them and could not: they were named outside their files, by kmain.c and syscall.c, both themselves unreached - the question cannot see "named only by code that is itself dead", and the transitive version is now recorded there as its next improvement. Baseline tightened 27 to 12 (core_dead_code.md). Second increment corrected the method as much as the code: the claim that kmain.c had 17 dead functions was wrong, and wrong by the weak question - the arch layer names only vibeos_kmain, but vibeos_kmain calls the rest. A transitive closure reaches 16 of 18; that file holds the live kernel CLI, and acting on the claim would have deleted it. Two functions were genuinely unreached, 47 lines: a trap wrapper the arch layer bypasses and an accessor the CLI does not use - the latter checked before removal precisely because health= prints every boot. So the weak question errs in both directions: it missed two whole subsystems and would have condemned a live interface. Third increment took C3's decision on syscall.c: 2282 lines gone - the dispatcher (1502), syscall_policy.c (23), test_syscalls (687), four capability functions in user/lib/user_api.c (52). The decision rule is which choice gives C4 the smaller diff, and the measurement went against integrating: both dispatchers are switch statements, and C4 wants a table - a syscall id mapped to the checks that apply - so integrating meant carrying 1502 lines of the wrong shape into the change meant to fix the shape. The 23 lines that were the right shape are kept as prose in docs/core/architecture.md: a design does not have to be compiled to survive. The finding worth more than the lines: chasing the policy layer's callers established that vibeos_sec_* appears zero times in the arch layer - the kernel that boots has no capability model at all, so C4 is not "move the policy layer down" but "there is none on the metal; write one". Two tests went red on the way out and both were the same family: vibeos_user_api_capabilities() still reported four features whose implementations had just been deleted - configured and consulted by nobody inverted, a declaration nothing implements, and a capability flag is read precisely to decide whether a function exists, so the assertion was inverted rather than removed and now fails if any comes back non-zero; and one assertion checked the effect of a removed function, which is not lost coverage. process.c (1142) is deliberately untouched - it is the subject of C5, and deleting it in a dead-code commit would decide that phase sideways without the comparison the phase exists to make. Four external reviews verified finding by finding. The fourth asked the question the others did not - what depends on the reverse map being complete? - and the answer was a property written down nowhere: every consumer cross-checks rmap_count against vibeos_frame_owners, which is taken by the mapping itself and knows nothing about the node pool, so an under-recorded holder makes the two disagree and the operation refuses. Reclaim degrades; nothing is corrupted. That property is in rmap.h now and enforced by check-rmap-crosscheck.py, validated by sabotage. The check was wrong on its first run - a bare read that only bounds an array - and the fix was a marker requiring prose rather than a wider window, so a second bare use is noticed instead of forbidden. And the branch none of the four reviews found: rmap_add fails two ways and only one was counted, so a frame outside the region went unrecorded in silence; it increments untracked now. The third titled its table "verified and reachable" and none of its four findings is reachable; two of them are word for word defects the core plan has listed for weeks. The second, and three of its four concurrency findings are in code no process reaches: the syscall dispatcher is called only from host tests, vibeos_sec_audit_record only from that same dispatcher, and grepping the arch layer for vibeos_sec_* returns nothing - the running kernel never calls the security layer at all. Third independent confirmation of C3's subject, and the defect list in that half has grown from three to six, which is evidence for deleting it rather than hardening it. Two findings were real with the wrong mechanism, both worse than stated; one genuine hole the review missed: rmap_add returning -1 for a frame outside the pool increments no counter, so that mapping is silently untracked (external_reviews_verified.md). The four unreachable files now say so in a banner, with the command that verifies it - three reviews in a row read them as live kernel and nothing in the file told them otherwise. And a defect in the checks came out of it: warnings= filtered only build-id while clang-warnings= also filtered unused-command-line-argument, so a clean build always read warnings=8 - which is why four real -Wcomment warnings were dismissed as transient three times in one session. A counter that is never zero is a counter nobody reads; both filters match now and a clean build reads zero. Yesterday's test debt is paid: the fork/COW fix now has a deterministic test, and it took three arrangements - the first two asserted the right property about a setup where the defect could not appear, the sixth instance of that shape here. In Progress (ring-3 runtime and shell baseline verified). C0 started: this kernel now has a performance measurement, the first it has ever had - three hot paths counted and timed (syscall dispatch, the resolved page fault, the context switch), gated only on being non-zero. Two claims died on contact with the first numbers, which is the point of taking them: the mean is not a baseline (the first boot read 46 million cycles per syscall, because a blocking syscall is timed across the block - it was reporting how long waitpid waited, so a minimum was added and reads about 600 cycles), and the counts are not deterministic as the plan assumed - six boots spread 4.7% on syscalls, 18% on faults, 21% on switches, so nothing carries a tight ratchet yet and the threshold waits for a characterised spread rather than being written from one sample. A monotone run of five boots looked like drift and got a mechanism written for it; the sixth boot went back up and the mechanism was deleted (core_perf_baseline.md). C1 started: check-reachable.py had been written two days earlier and never wired into check.sh - "configured and consulted by nobody", the defect docs/core/ names as this project's most repeated, committed inside the document that names it. It runs now, with a new check-chokepoints.py: each security guard declares its call-site count and a move in either direction fails. Measuring changed that design - hw_user_range_ok has 44 sites because every syscall taking a user pointer validates through it, so the dangerous direction is the count going down, a syscall that stopped checking, which reads like a simplification and nothing else would see. It caught its author first: two of five declared numbers were set from a narrower grep than the check walks. Sabotaged both ways, red both ways (core_c1_checks.md). C1 finished, and both of its last two checks contradicted the document that asked for them. check-blast-radius.py measures what docs/core/ calls the plan's real progress metric - chosen precisely because line count had been the criterion twice and failed twice - and the plan's own table said a filesystem and a block driver each cost 1 file. Measured: 4, 4, 4, 4, 3, 2 across six extension points, total 21. Nothing costs 1; the floor is 2. The four filesystems cited as evidence never used the registry - ext2, ntfs, exfat and iso9660 are named directly in g_probes[] with a mount wrapper each and a member in vibeos_volume_t, so vibeos_storage_register has exactly one caller. And that one caller, FAT, measures 4 as well: somebody still calls the register function, declares it and mounts through it. A registry whose members need a bring-up call has the same blast radius as no registry at all - a far sharper target for C6 than "move code out of arch_hw.c", and unsayable before the number existed. The check corrected its author twice on the first run: FAT was hand-counted as 2 by a grep that missed the two symbols which are the seam, and a shared symbol prefix turned out not to be a module (vibeos_x86_64_fat_ matches both the VFS driver and the raw FAT reader, giving 7 for something whose answer is 4). check-subsystem.py covers four of the seven module parts across 50 modules and found one already perfect: exported_state=0 - no module's state has a name another file can write, measured with nm rather than a regex because the linker's opinion is the only one that counts. Baselines no_header=15, no_case=35, state_without_lock=3. That last property had to be narrowed after reporting 29 on its first run, including blkdev.c, which holds its own lock: a detector that flags correct behaviour is one people learn to ignore. Asking instead for the shape that actually cost months - mutable file-scope state and no mention of a lock anywhere - gives three: vfs.c (the mount table), anon.c (a clock hand), forkguard.c (the fork budget). backing.c was that exact shape and was right for months on the accident of one caller. Two of the seven parts are deliberately absent with the reason stated: the must-be-zero counter is C2's step 3, which adds counters before the check demanding them, because a check shipped red against thirty modules is the state check-mm-layering.sh sat in for a phase. Both wired into check.sh, both sabotaged and seen red (core-contract.txt, core_c1_blast_radius.md). Four planning documents corrected rather than appended to - README, architecture, invariants and phases all carried the radius-of-1 claim: the plan named a progress metric, gave it a value, and nothing measured it for the whole time it was the metric. C2 started, and its enumeration produced a defect before it produced a list: 33 report tags against 6 lines carrying MUSTBEZERO, of which the gate read 5. [GUI] MUSTBEZERO guard_broken= was printed on every boot for the whole of C0 and asserted by nobody - and it was added by the same person, in the same session, that had just quoted CLAUDE.md's "a line in the serial log is not a check". Knowing the rule and walking into it anyway one session later is the argument for mechanising it. Neither existing check-on-checks could see it, and it fell exactly between them: check-counters-produced.py asks whether anything can move a reported counter and the ++ satisfied it; check-assertions-covered.py matches every reason= against the sabotage cases and guard_broken never appeared in one, which is precisely what was missing. A counter produced and never asserted passes both. check-mustbezero-asserted.py is that third axis - every MUSTBEZERO <name>= literal under kernel/ against every such pattern the gate searches for - and it reported unread=1 against the real tree, not a simulation. The gate asserts it now, and the assertion has been seen red on metal: writing one guard word of the back buffer as zero took the boot from status=pass to reason=invariant_failed:gui_guard_broken=1, counting the single word broken rather than the whole buffer (gui-guard.txt). Recorded and deliberately not fixed: that report line is twelve serial_puts calls with no serial_lock, which by this project's own rule is twelve critical sections - it has not misread yet only because it runs late, which is an accident of timing and the same sentence already written here about a page cache with one caller (core_c2_observability.md). Second C2 finding, and this one had a comment vouching for it: hw_cache_audit compares every resident page-cache page against the bytes its file holds - 1821 pages on the boot it was found - and the gate never read the answer, while the comment above the function said "Reported as a count, and the boot gate asserts it is zero." A guarantee documented in the source and provided by nobody is worse than an undocumented gap, because the sentence is what the next reader trusts instead of checking. And the check written an hour earlier for exactly this shape reported ok against it, because it mechanises a word and not a property: a counter opts in by spelling MUSTBEZERO, and one that never spells it is as unwatched as before. Stated rather than papered over. The harm is the worst class this project has had - once an image page is mapped from the cache rather than copied, one frame is the text of every process running that program. Two assertions added, not one: cache_audit_changed zero and cache_audit_checked non-zero, because an audit that examined nothing reports the healthy answer byte for byte, which is the state VIBEOS_BLK_TIMEOUT was asserted in for months. Sabotaged on metal both ways: forcing one entry to differ gives exec_cache_page_changed=1 with a witness naming file, offset, phys, byte and both values; returning early gives exec_cache_audit_checked_nothing. Removing the word leaves the check green at counters=6 and turns the gate red instead - the check does not fail when a counter leaves its view, it stops counting it, and that asymmetry is recorded rather than smoothed over (exec-cache-audit.txt). The case file was first written with ran: yes on four cases before any had been run; running them proved two of the four descriptions wrong, and both are corrected in place. A sixth external review arrived titled "only verified and reachable problems", and all three of its findings are in kernel/core/syscall.c - deleted the same day. Verified rather than asserted: at the parent commit the arch layer never named vibeos_syscall_dispatch, so none of the three was reachable from ring 3; waitset_initialized and kernel_waitset now have zero matches in the tree; boot_event survives only as an init and one signal in kmain, reached by no syscall. And H-001's shape cannot exist in the live kernel at all: the real path is hw_sys_clone_thread, Linux clone(), whose parameters are flags, stack, ptid, ctid and tls - there is no target process, the parent is g_current_task, so a caller can only create a thread in itself. The principle behind it is right and the live kernel does implement it: every target-pid syscall goes through hw_signal_permitted, one definition and four call sites, and check-chokepoints.py fails if that count moves in either direction. Fourth consecutive review to land entirely in unreachable code, and the cause is structural rather than careless - kernel/core/ reads like the kernel, so every hour spent there is an hour not spent on arch_hw.c, where every security defect this project has actually had was found. What still has that shape is kernel/proc/process.c: 1142 lines with a process table, thread creation and a capability model nothing consults. A seventh review can still land in it, which is the first argument for taking C5 sooner that came from outside the project. A sixth review then landed in the live path and was right. munmap cleared the entry, invalidated this core's translation and put the frame back, so a sibling thread on another core could write into a frame the allocator had already reissued - and the source admitted it, in a comment saying a shootdown "belongs here too, and is deliberately absent". A synchronous shootdown stays rejected for the measured reason (two runs in twenty-four failed with tlb_acks below shootdowns; syscall clears IF so a core cannot answer the IPI, and munmap runs far more often than fork). What the comment never considered is the third option the review proposed: deferred reclamation, where nobody waits for anybody. The frame is parked and released once every other core has loaded CR3 - valid because this kernel enables no PCID and marks no page global (both checked, both now named in the source, because the argument dies silently if either changes) and because every context switch loads CR3 unconditionally. Same shape as dead_kstack_base, already in the tree. The first version had a hole in exactly the case that matters: hw_schedule returns early when the next task is the current one, so a core running a single thread never reloads CR3 - and a sibling thread spinning on another core is both the thing holding the stale translation and the thing that would never advance. The easy case would have been fixed and the hard case would have degraded to the old behaviour, with no green boot able to tell them apart. Closed by flushing on every core's next tick while anything is parked; the live peak fell from 175 slots to about 45. Ten boots on four vCPUs: ~320 frames deferred each, released equal to deferred every time, overflow zero every time, frames_leaked still zero. deferred is asserted non-zero - the race cannot be produced on demand, but "munmap took the safe path at all" can, and a mechanism that silently stopped would leave every boot green with the defect untouched; overflow is deliberately not asserted, because falling back is exactly the old behaviour and never worse (mm_tlb_quarantine.md). And the same reviewer then found a second gap, in the mechanism meant to close the first. hw_tlb_shootdown counted a timeout, logged it and returned - and every caller has already narrowed a permission or removed a mapping by then, so returning quietly leaves a core writing through a permission that has been revoked, into a page a fork has just shared, with no copy-on-write fault. Worse than stated: no caller could have checked, because the backend hook is void (*shootdown)(uint64_t) - there is no return value, at any of its five call sites. The fix that closed munmap does not transfer, and saying why is the useful part: there the resource in danger was a frame and could be parked; here it is a permission already narrowed, and the entry must be written before the shootdown is asked for or there is nothing to invalidate - so neither deferral nor rollback is available by the time the timeout is reached. The remaining choices were to wait longer or to stop, and a core that has not answered in two hundred million spins is not about to: it panics naming the reason, which is this project's own position - the difference between a machine that stops and one that says why - and a silent memory-corruption path is strictly worse than a named halt. Recorded as untested: the harness cannot make a core miss an IPI for that long, and lowering the bound would collaud a different machine; tlb_timeouts is zero on every boot here but has moved historically, about three in thirty-two when the shootdown broadcast to every core. The gate already matched the timeout string, which is a detector and not a fix - the operation in that boot had already completed wrongly - and only in CI. Two MEDIUM findings from the same reviewer fixed, and a HIGH reverted. brk shrinking removed the region and left the page-table entries present and the frames allocated, so memory a program had handed back was still mapped and readable - the region list and the page tables disagreeing, the same shape as the munmap defect. mmap had two leaks on its out-of-memory path and only one was obvious: the frame allocated when hw_map_page was what failed had no owner to release it, while the pages already mapped were recoverable by accident, because mmap_cur is not advanced so the next mmap reuses the base and map_raw releases what it overwrites - narrower than reported and written down as such. The same two leaks were in hw_map_user_pages, which the review did not look at and which brk grows through; grepping for the shape turned one fix into two. A worse consequence was hypothesised and checked and disproved before being written down. H-003 (kernel dereferencing a user pointer after a concurrent munmap) is REVERTED and open, and the diagnosis is worth more than the attempt: the recovery mechanism measured 3/3 green and had never once run, because its only caller was the futex path where the word is always mapped. A probe reading an unmapped user address from ring 0 on every boot turned that into a #UD in one run - recovered rip=0xaa020 against a kernel linked at 0x4000000, so &&label had produced a section-relative offset rather than an absolute address. Not patchable by adding the base; it needs a real exception table with a relocated stub. The probe also had to be moved before it could say anything: run from kmain it made the machine go silent - 551 lines, no trap, no panic - because kmain runs after every user task has exited and the loaded CR3 can belong to a torn-down address space. The bisection cost an afternoon and the step that should have come first was booting HEAD to confirm it was still green (mm_brk_mmap_leaks.md, uaccess_recovery_open.md). C5 was taken before C4, as a step 0 the plan did not have. Five external findings in two days - the mmap cursor, the region list, signal dispositions, exit_group and descriptors - were one defect: clone did child->proc = parent->proc, a by-value copy, so everything that belongs to a process was a private copy per thread. The worst of them was in no review: a thread's mmap never moved main's cursor, so main was handed the same base and its fresh pages silently replaced the thread's. And the sigaction copy loop sat directly under the comment "Signal dispositions are the process's, so a thread inherits them." The existing threads test could not see any of it, because every thread was created by main and no worker touched process state. Tests first, red on the unchanged kernel: THREADS_C5_MMAP_FAIL: main was given the thread's base 0x80040d2000, THREADS_C5_SIGACTION_FAIL: killed by signal 10, THREADS_C5_EXIT_GROUP_FAIL: exited 7, expected 42 - with the gate compiled, the build at rc 0 and both binary and log newer than the source, so a stale image could not fake it. The exit_group stage demands 42 rather than "it ended", because waitpid builds the status from the leader and an exit_group made of SIGKILL alone would report "killed by 9". The change: hw_procstate_t, referenced not copied - fork and exec create one, a thread takes a reference, the last reference empties the region list before the slot is published free. The mmap cursor is claimed by compare-exchange before mapping, because sharing it turned a deterministic defect into a race and a lock across the mapping would hold thousands of pages with the timer off; brk runs one at a time per process for the same reason. exit_group records the code, SIGKILLs the siblings, and delivery reports the code; exec derives the new process's dispositions before giving back the old reference, since that slot can be reissued the instant it is free. Green on the first build, check.sh all green, 12/12 boots. Rewriting mmap for it exposed that this morning's M-002 fix covered one of two allocation loops - the reservation branch - and missed the ordinary path malloc reaches, where the leftovers are PTE_USER; the note claiming ring 3 could not reach them was true only of the loop it fixed. Both unwind now and the document carries the correction. Recorded as open rather than claimed, each found after the change was green: no blocking wait in the kernel reads sig_pending - futex, pipes, waitpid and console read all re-block after a signal wakes them - so exit_group cannot end a sibling blocked on an unrelated condition, and kill -9 cannot end a process blocked on a pipe; the Ctrl-C case file had already recorded that delivery to a blocked process has never been observed by any test. wait4 ignores its options, so WNOHANG blocks. fork snapshots the break without holding it. exec in a threaded process does not end the siblings (H-006, verified, with the sharper consequence that a non-leader's exec'd program is released as a thread and its exit can never reach the parent). M-004 verified and open: pipes decide end-of-file outside their lock, and the read half - which the review called optional - is the worse one, reporting EOF with written data still in the buffer (core_c5_process_state.md, pipe_eof_race_open.md). Two more of step 0's open items closed, each red first. wait4 never received its options - the dispatcher passed only two arguments - so WNOHANG blocked, which stalls a shell reaping background jobs and made every "bounded" poll in a test unbounded; the red stage was built so the defect could not stall the boot, with a child that exits on its own, and it failed with waited for the child to exit. WNOHANG is honoured now; the other Linux wait bits are accepted and ignored, because refusing WUNTRACED would break BusyBox's shell, and unknown bits are refused. And hw_task_by_tid was return hw_task_by_pid(tid); under a comment saying thread and process ids were the same number - true when written, false since clone gave threads their own ids - so tkill answered ESRCH for a live thread (pthread_kill on a live thread returned 3), tgkill could refuse even the leader depending on slot order, and raise(), which is tkill(gettid()), failed in every thread but the first. Both green on the first build with the BusyBox stages intact, check.sh all green, twelve boots: 11 pass, 1 fail (core_c5_process_state.md). One of those twelve boots failed, and was read rather than counted: missing:unrecoverable, a kernel trap whose frame decodes as ASCII file names from the self-test script, then execution at 0x3030303030303030 (00000000), with three cores on THREADS.ELF in the four-worker stage - which touches neither wait4 nor tkill. A log from 2026-09-08, three days before any of today's changes, has the same shape in the same stage, so it is recorded as a second, pre-existing intermittent family, separate from the known CLI_READY one, mechanism unknown (boot_repeatability.md). Then the waits. No blocking wait in the kernel had ever asked whether a signal was pending - futex, pipe read and write, waitpid and console read all went straight back to sleep when a signal woke them - so exit_group could not end a sibling blocked on anything but the exiting thread's own join, and kill -9 could not end a process blocked on a pipe. The Ctrl-C case file had already recorded that delivery to a blocked task had never been observed; there had been nothing to observe. The red stage blocks the leader on a condition nobody signals, because a blocked worker would let the leader exit 42 and pass on the defect, and it polls with WNOHANG, which is why it waited for that fix: THREADS_C5_EXIT_GROUP_BLOCKED_FAIL: the process never ended. Every wait asks hw_signal_interrupts now, blocked first and asked second, because hw_task_set_state takes no lock and raise wakes without g_sched_lock; futex decides woken-or-interrupted under its own lock so a concurrent wake is never lost; and ignored or default-ignore signals interrupt nothing, since the native shell ignores wait4's result. Green, check.sh all green, twelve boots: 11 pass, 1 fail. The failure was read, not counted: FREE_WHILE_MAPPED ... during destroy mappers=0x2 owners=0x1, field for field the open signature cow_stale_page.md has carried as this project's oldest defect, in svc_bomb's fork storm - a path this change does not touch. It carried one thing the original did not, at_va at the top page of a native program's stack, which is recorded there as a direction rather than a finding (cow_stale_page.md). And a stall no longer hides its own diagnosis. The blocked-sibling red run reported missing:CLI_READY while THREADS_C5_EXIT_GROUP_BLOCKED_FAIL sat in its log: a missing: reason is raised while the gate drives the CLI, before any invariant runs, and a defect that leaves a process alive stops userland from ever finishing. The gate now appends stage_failures= - every NAME_FAIL already printed - to a missing-marker reason. Annotation only; the invariants still decide. Deliberately not FAILED, which init prints for services that fail by design. Validated rather than asserted: the regex was tried offline first, then the real file's own self-tests ran under the gate's Python, its function found exactly the one failure in the stall log and nothing in four crash logs, and an in-memory sabotage accepting FAILED tripped flags SVC_FAILED. It has not yet been exercised by a live stall, and says so. Futex waiters now belong to a process. Reading hw_futex_wake while chasing the four-worker crash showed the waiter table keyed by user virtual address alone - with every Linux program linked at 0x400000 and a forked child sharing its parent's layout, a wake in one process could end a wait in another. It is not claimed as that crash's cause, since musl re-checks after every wakeup. The red stage had a deterministic sign that needed no timing luck: the parent, with no waiter of its own, was told its wake had woken one (THREADS_C5_FUTEX_XPROC_FAIL: a wake in this process woke 1 waiter(s) in another). Waiters record their hw_procstate_t; the exit path's wake of clear_child_tid relies on running before the process reference is given back, and the comment beside it says so, because reversing that would hang every join silently. Green, check.sh all green, twelve boots: 10 pass, 2 (both the known four-worker family: the same unhandled write inside THREADS as fail-7 before the fix, shifted by the new stage's 0x320 bytes) fail (core_c5_process_state.md). H-006: exec from a thread now ends the other threads and the caller takes the process id. The siblings used to keep running the old image and a non-leader kept its tid and thread flag, so the parent could never see the new program exit. After the image loads, the leader is marked a thread under the scheduler lock, siblings get SIGKILL by thread-group id, exec sleeps until they are gone (EINTR and a destroyed uncommitted image if a signal must be acted on), then pid = tgid. Exec also clears clear_child_tid, which the new image would otherwise have zeroed at exit. Red first (THREADS_C5_EXEC_FAIL: exited 7, expected 23), check.sh all green, twelve boots: 12 pass, 0 fail. H-007: a pid resolved to a task slot now still names that task when it is used. kill, tkill, tgkill, setpgid, getsid and setsid - and the sibling loops of exit_group and exec - found a slot index and used it unlocked, so a slot reaped and refilled by fork in between took the signal or the write. Lookups also matched slots still being built, which keep the previous tenant's ids. Lookup, check and act are now one critical section under g_sched_lock, the lock every allocation and every publish to FREE is taken under, and lookups skip RESERVED slots. Two comments claiming the lock could not be held there were wrong. No red test: the window is a few instructions between two cores and does not reproduce in a boot; the existing signal stages are the regression. check.sh all green, twelve boots: 11 pass, 1 fail - the four-worker THREADS family, same unhandled write at the same instruction (0x405b72 in this build), ending in the free-page-poison panic. M-006: a length that wraps when rounded to a page is refused. mmap computed (len + 0xFFF) / 4096, zero pages for a length near 2^64, and returned success having mapped nothing, with the base the next call would get; munmap and mprotect had the same wrap in their aligned end, which made munmap drop every region above the address with the pages still mapped. mmap answers ENOMEM, munmap and mprotect EINVAL. Red in the ring-3 ABI self-test first, then split: with only the mmap half fixed the test stayed red, proving the munmap/mprotect checks fail on their own. check.sh all green, twelve boots: 11 pass, 1 fail - task_illegal_transition (running->running by hw_task_exit) in THREADS' exit_group stage, a scheduler-exit signature this change does not touch and seen for the first time today; recorded with the exit-window evidence as its likeliest cause, H-007's locked exit_group loop not yet ruled out.A seventh review scored the project 6.8/10 with a CRITICAL arbitrary-kernel-write, and its author confirmed on request that the paths were archetypes rather than this repository - nine of its ten cited files do not exist here. Checked anyway, finding by finding: five of seven false against the code (hw_user_range_ok has 44 call sites under a chokepoint check; the frame refcount is 16-bit under the layer's own registered lock; elf.c:242 already does the exact checked addition and range test the review recommends; hw_spin_lock begins pushfq; popq; cli; IPv4 and UDP both compare declared length against actual). Two were true and both already fixed - the TLB shootdown above, and the FPU/SSE context switch, which no other review found and which was real for this kernel's whole life. An archetype aimed at the wrong tree still named where this class of kernel breaks (review_seventh_verified.md). Refactor planned and the plan revised end to end Refactor planned and the plan revised end to end (docs/core/): it was a tidying plan built on a line count, and that criterion has been tried twice and failed twice - both times the file grew while the document naming its length as the completion criterion sat unchanged. It is now built on blast radius: adding a filesystem or a block driver touches 1 file because those layers are registered into; a character device, an input device, a network interface, a display, a syscall or an ABI all mean editing arch_hw.c. Four filesystems and four drivers arrived without their layers being touched, which makes that a controlled experiment rather than a preference. Measured rather than remembered: arch_hw.c is 10,087 lines in 19 sections, of which 7,726 leave and the largest single block - 4,169 lines, 41% - is the Linux ABI, which the next refactor on the roadmap has to move anyway, so the file cut and the multi-ABI layer became one phase instead of two. Two findings changed the plan: the scheduler phase is already done (hw_pick_next consults vibeos_sched_policy_pick and falls back to vibeos_runq_pick), and observability is far thinner than assumed - five must-be-zero counters against 25 report tags - so it earned its own phase ahead of every move. Performance has never been measured here at all, so C0 builds a baseline before any seam is added; the boot gate has been recording phase_history since it existed and nothing has ever read it. check-reachable.py is written and running (unreached=12 baseline=27), and the two that matter are the second dispatcher - 2,592 lines the arch layer names zero times, carrying three uncorrected defects, which nothing may reach from ring 3 until they are closed