Vision

Mission

VibeOS aims to be a modern operating system built from scratch in C and C++ for production-grade research, advanced systems experimentation, and eventual real-world deployment. The system is designed to provide strong performance, principled security, and a compatibility strategy that can host applications originating from Linux, Windows, and macOS.

Design principles

1. Security first

Security is a first-class architectural property, not an add-on. The system is designed around least privilege, strong isolation, memory protection, capability-aware resource access, and explicit trust boundaries between kernel, services, drivers, and applications.

2. Performance without hidden complexity

Fast paths must stay simple, measurable, and observable. Kernel transitions, scheduling, IPC, memory management, and I/O paths should be designed with low overhead and scalability on many-core systems.

3. Modular evolution

The OS must support distributed development by allowing major subsystems to evolve independently behind stable interfaces. This includes driver hosting, filesystem services, compatibility runtimes, networking, and userland service layers.

4. Compatibility as architecture

Running third-party software from Linux, Windows, and macOS must influence core design decisions from day one. The OS must expose the right primitives for compatibility layers, ABI shims, containerization, and lightweight virtualization.

5. Portability by construction

The architecture should separate machine-independent kernel code from platform-specific code. Initial implementation targets x86_64, but interfaces should anticipate future ARM64 support.

Product goals

Performance goals

  • low-latency scheduling and interrupt handling
  • low-overhead system call and IPC paths
  • scalable multicore execution with minimized global contention
  • NUMA-aware evolution path for future hardware targets
  • efficient page fault handling and virtual memory operations

Security goals

  • strong process and service isolation
  • explicit privilege domains
  • memory-safe subsystem boundaries where practical through language and interface discipline
  • hardened kernel build and runtime protections
  • sandboxing for untrusted applications and compatibility runtimes

Compatibility goals

  • Linux application support as the first compatibility priority
  • Windows user-mode compatibility as the second major priority
  • macOS compatibility pursued selectively through runtime, API translation, and managed execution strategies rather than full binary parity in early phases
  • architecture support for containers, para-virtualization, and restricted guest environments

Non-goals for early phases

  • immediate support for all hardware classes
  • complete POSIX, Win32, or Darwin kernel parity in the first milestones
  • broad consumer desktop readiness before core kernel reliability
  • unrestricted kernel extension ABI stability before internal interfaces mature

Success criteria for the project foundation

Phase 1 is successful when the project has:

  • a clear architectural stance
  • documented subsystem boundaries
  • repository organization suitable for long-term work
  • milestone sequencing that de-risks the kernel bring-up path
  • a compatibility strategy that does not compromise kernel integrity

Architecture

Kernel architecture options evaluated

Monolithic kernel

Advantages:

  • best-case performance due to fewer context switches and direct internal calls
  • simpler early boot and subsystem bring-up
  • mature development pattern used by several high-performance kernels

Disadvantages:

  • weak fault isolation for drivers and services
  • larger trusted computing base
  • harder long-term maintainability for a compatibility-heavy OS
  • greater risk when integrating experimental subsystems

Microkernel

Advantages:

  • strong isolation for drivers, filesystems, and services
  • smaller privileged kernel core
  • better resilience and restartability of failed services
  • natural fit for compatibility subsystems living outside kernel mode

Disadvantages:

  • IPC overhead can harm performance if not designed carefully
  • more complex service orchestration and bootstrapping
  • harder path for latency-critical subsystems if purity is enforced rigidly

Hybrid kernel

Advantages:

  • allows microkernel-like compartmentalization without forcing every service out of kernel space
  • lets the project keep latency-critical primitives in privileged space
  • supports pragmatic evolution as performance data becomes available

Disadvantages:

  • architectural discipline is harder to maintain
  • can devolve into a monolith without strict module boundaries
  • requires careful criteria for what may run in kernel space

Modular monolithic kernel

Advantages:

  • practical implementation path
  • kernel modules can be developed independently
  • strong performance characteristics

Disadvantages:

  • modules still share the same failure domain
  • weaker security boundaries for drivers and compatibility components
  • poor fit for long-term least-privilege goals

Final decision

VibeOS adopts a hybrid microkernel-inspired modular architecture.

Rationale

This design best satisfies the project goals:

  • performance-critical primitives remain in a compact kernel core
  • compatibility services and risky subsystems can run outside the kernel
  • the system can evolve toward stronger isolation without rewriting the core
  • user-space services can host Linux, Windows, and macOS translation layers more safely than a monolithic compatibility design

High-level structure

Ring 0 kernel core

  • trap and interrupt management
  • virtual memory primitives
  • scheduler and CPU topology management
  • thread, process, and handle core objects
  • IPC and synchronization primitives
  • kernel object namespace and capability enforcement
  • timekeeping and low-level hardware abstraction

Privileged kernel modules

Allowed only for measured performance or boot-critical reasons:

  • physical and virtual memory backends
  • select storage and network fast paths
  • architecture-specific low-level platform support

User-space system services

  • driver hosts
  • filesystem servers
  • network control plane services
  • process manager extensions
  • security policy engines
  • compatibility subsystems
  • service manager and session manager

Placement policy

To prevent the hybrid design from collapsing into an undisciplined monolith, a subsystem may run in kernel space only if it satisfies at least one of the following:

  • it is required before the first trusted user-space service can start
  • it sits directly on an interrupt-latency-critical path
  • it would impose unacceptable context-switch or copying overhead if moved out of kernel space
  • it enforces a protection boundary that cannot safely be delegated

Everything else defaults to user space. Any exception should be justified by measurement, not convenience.

Core abstractions

Execution model

  • CPU
  • kernel thread
  • user thread
  • process
  • job or cgroup-like resource domain
  • protection domain

Resource model

  • handle-based object access
  • capability-tagged rights for sensitive objects
  • explicit namespace boundaries
  • service endpoints for message-based interaction

Communication model

  • fast local IPC with shared-memory assisted channels
  • async and sync message passing
  • event objects and wait sets
  • brokered cross-domain communication for sandboxed applications

Portability strategy

The codebase is split into:

  • kernel/arch/ architecture-specific code
  • kernel/platform/ firmware and board integration
  • kernel/ architecture-independent core
  • user/ user-space services and applications

Initial target:

  • x86_64 with UEFI boot and SMP

Planned next target:

  • ARM64 with UEFI or platform loader support

Compatibility architecture placement

Compatibility is not implemented inside the core kernel ABI. Instead, VibeOS exposes kernel primitives that allow:

  • Linux personality runtimes
  • Windows subsystem servers
  • macOS-oriented runtime environments
  • lightweight virtual machines for unmodified workloads where translation is not viable

This reduces kernel ABI bloat and keeps policy and emulation logic outside ring 0.

Architectural constraints

The design deliberately avoids:

  • exposing foreign kernel ABIs as first-class native kernel interfaces
  • allowing user-space subsystems to depend on unstable private kernel structures
  • placing full filesystem parsers or broad driver stacks in ring 0 without demonstrated need

Where the code currently differs. The five filesystem drivers, the virtio block and network drivers and the PS/2 input drivers are in ring 0 today, not in user-space services. That is a deliberate staging decision - getting the semantics right in one address space first - and not a revision of the target above. The layering is arranged so the move is possible: a filesystem driver reaches the disk only through vibeos_blockdev_t, and callers reach a filesystem only through vibeos_fs_ops_t, so neither side names the other. It should be read as debt with a plan, not as the design.

  • promising complete foreign application parity before subsystem maturity and legal review

Software Architecture Specification

0. Scope and Source Inputs

This document consolidates the project-manager-level material already present in the repository into a single software architecture specification suitable for system engineers. It is the primary technical reference for Phase 2 and later implementation work.

Source inputs:

  • project vision and goals
  • architectural direction and subsystem design notes
  • compatibility strategy
  • development roadmap and risk register

Primary source documents already available in the repository:

  • docs/vision.md
  • docs/architecture.md
  • docs/boot.md
  • docs/kernel_design.md
  • docs/memory_management.md
  • docs/scheduler.md
  • docs/filesystem.md
  • docs/security.md
  • docs/compatibility.md
  • docs/ipc.md
  • docs/toolchain.md
  • docs/testing_strategy.md

1. Architectural Vision

1.1 System mission

VibeOS is a new operating system built from scratch in C and C++. It targets modern x86_64 systems first, with a planned ARM64 path, and is designed to combine:

  • a security-first execution model
  • low-overhead multicore performance
  • modular subsystem evolution
  • support for Linux, Windows, and selected Darwin or macOS-adjacent software through compatibility subsystems rather than native-kernel ABI cloning

1.2 Architecture style selection

OptionStrengthsWeaknessesDecision
Monolithic kernelbest raw syscall and in-kernel call performance; easier early bring-uplarge trusted computing base; weak fault isolation; poor fit for compatibility-heavy designrejected
Microkernelstrong isolation; restartable services; clean compatibility placementIPC cost sensitivity; harder early orchestration; more complex fast pathsrejected as too pure for early goals
Hybrid microkernel-inspired modular kernelkeeps critical primitives in ring 0 while pushing risky subsystems out of kernelrequires strict governance to avoid boundary erosionselected
Exokernelmaximal flexibilityhigh complexity for application and runtime authors; poor fit for project scoperejected

1.3 Selected kernel model

The system shall use a hybrid microkernel-inspired modular kernel with the following rule:

  • the kernel core contains only primitives that are unsafe, impossible, or measurably inefficient to host in user space
  • drivers, filesystems, policy engines, compatibility runtimes, and high-risk parsers default to user space
    • as implemented today, the filesystem and device drivers are in the kernel; see the note in docs/architecture.md for why, and for the interfaces that keep the move open
  • any privileged exception requires architectural review and benchmark evidence

1.4 Design drivers

DriverArchitectural consequence
Performanceper-CPU scheduling state, short syscall paths, shared-memory-assisted IPC, minimal global locks
Securityhandle-based object access, explicit rights masks, service isolation, sandboxing, secure boot compatibility
Modularityversioned subsystem contracts, user-space services, clear repository boundaries
Portabilityseparation of architecture-specific code from kernel core; UEFI-first boot path
Compatibilityforeign-OS behavior implemented above the native kernel ABI via runtimes and subsystem services

2. System Context and High-Level Structure

2.1 Layered model

Applications
  -> Native VibeOS apps
  -> Linux compatibility domain
  -> Windows compatibility domain
  -> Darwin/macOS-adjacent runtime domain

System services
  -> init
  -> service manager
  -> device manager
  -> filesystem services
  -> network services
  -> security services
  -> compatibility subsystem hosts

Kernel core
  -> scheduler
  -> virtual memory
  -> object model and handles
  -> IPC primitives
  -> interrupt and exception handling
  -> timekeeping

Platform layer
  -> CPU and SMP bring-up
  -> interrupt controllers
  -> timers
  -> firmware tables
  -> bus discovery support

2.2 Major execution domains

DomainPrivilege levelResponsibility
Kernel corering 0CPU control, memory protection, scheduling, object model, IPC, trap handling
Privileged kernel extensionsring 0only boot-critical or performance-justified fast paths
Trusted system servicesuser modeservice manager, policy services, device manager, core storage and network control
Compatibility runtimesuser modeLinux, Windows, and Darwin-oriented translation or guest integration
Applicationsuser modenative or foreign workloads under policy and sandbox constraints

2.3 Placement policy

A component may execute in kernel space only if at least one of these is true:

  • it is required before trusted user-space services exist
  • it is on an interrupt-latency-critical path
  • moving it out of kernel space causes unacceptable measured overhead
  • it enforces a protection boundary that cannot be delegated safely

All other components shall remain in user space.

3. Kernel Architecture

3.1 Process management

The process model is object-based.

Each process owns:

  • one virtual address space
  • one handle table
  • one security identity and rights context
  • one resource-accounting record
  • one or more threads

Optional higher-order containment objects, called jobs, group multiple processes for:

  • resource accounting
  • lifetime control
  • sandbox policy application
  • compatibility-domain scoping

Process creation modes shall include:

  • native image spawn
  • fork-like compatibility mode implemented above the native API
  • service host spawn
  • restricted sandbox spawn

3.2 Thread model

The kernel uses a 1:1 kernel-thread model for schedulable entities. User-space runtimes may build additional user-thread or coroutine schedulers, but the kernel is aware only of kernel threads.

Each thread owns:

  • CPU register state
  • scheduling attributes and affinity mask
  • exception state
  • thread-local storage metadata
  • wait state for IPC, timers, and synchronization objects

3.3 Scheduling

The scheduler is preemptive and priority-aware with fair distribution inside each priority class.

Mandatory properties:

  • per-CPU run queues
  • timer-driven preemption
  • migration support for SMP balancing
  • wake-up latency optimization for interactive threads
  • starvation prevention through aging

Initial classes:

  • realtime reserved for future controlled use
  • interactive
  • normal
  • background

The first implementation may use round-robin within each priority band, but the contract shall allow later evolution toward weighted fair scheduling and deadline classes.

3.4 IPC

IPC is a first-class kernel subsystem because the architecture relies on service isolation.

Mandatory IPC primitives:

  • message channels with preserved message boundaries
  • shared memory objects for bulk transfer
  • events for low-cost signaling
  • wait sets for multiplexed blocking

IPC requirements:

  • secure handle transfer
  • bounded queues with backpressure
  • synchronous request-response capability
  • asynchronous notification support
  • traceable latency and queue metrics

3.5 System call interface

The native syscall interface shall be compact, handle-based, and versioned.

Native syscall groups:

  • process and thread lifecycle
  • address-space and memory-object management
  • channel, event, timer, and wait operations
  • device and I/O primitives
  • trace and diagnostic support
  • security token and rights operations

The native syscall ABI shall not mirror Linux, Win32, or Darwin kernel APIs directly. Foreign-OS compatibility must be implemented above the native interface.

4. Hardware Abstraction Layer

4.1 Scope

The HAL provides the minimum architecture-specific layer required to hide platform differences from the kernel core.

Responsibilities:

  • CPU feature discovery
  • privileged instruction wrappers
  • interrupt controller integration
  • timer device integration
  • memory-map ingestion from firmware
  • SMP bring-up support
  • bus and DMA mediation hooks for drivers

4.2 CPU management

For x86_64, the HAL shall support:

  • bootstrap processor and application processor bring-up
  • CPU-local storage and per-CPU structures
  • context-switch support
  • feature detection for paging, timers, and security capabilities

The design must isolate architecture-dependent code under kernel/arch/ and kernel/platform/ to permit ARM64 introduction without rewriting generic schedulers, IPC, or object-management logic.

4.3 Interrupt handling

Interrupt flow:

  1. low-level trap entry in architecture code
  2. interrupt source acknowledgement
  3. dispatch to kernel interrupt handling layer
  4. wake or schedule affected kernel or user-mode service work

Requirements:

  • bounded interrupt-disabled sections
  • deferred work outside the hardest interrupt path
  • explicit distinction between exceptions, interrupts, and inter-processor interrupts

4.4 Hardware compatibility

Phase 1 assumptions:

  • UEFI firmware
  • x86_64 SMP
  • QEMU as the first validation platform
  • PCIe-centric discovery path

Later hardware enablement should proceed by adding device classes and platform adapters, not by widening kernel-global assumptions.

5. Memory Management

5.1 Physical memory management

The physical memory manager shall:

  • ingest the firmware memory map
  • classify regions into usable, reserved, firmware, device, and special-purpose ranges
  • expose page-frame allocation services
  • support DMA-constrained pools where required

Recommended first implementation:

  • early boot bump allocator
  • runtime buddy allocator for general page frames

5.2 Virtual memory

The virtual memory subsystem shall support:

  • per-process address spaces
  • higher-half kernel mapping
  • direct physical map for kernel internals
  • demand paging
  • copy-on-write
  • shared memory mappings
  • file-backed mappings
  • explicit protection changes

5.3 Allocators

AllocatorScopeNotes
boot bump allocatorearly kernel startupno free operation required
buddy allocatorphysical framespage-granular allocation
slab or object cacheskernel fixed-size objectsprocess, thread, channel, timer, handle table elements
general kernel heapvariable-size kernel allocationsnon-boot dynamic data
user-space allocatorslibc/runtime levelbuilt on native memory syscalls

5.4 Protection and hardening

Mandatory protections:

  • user/kernel address-space separation
  • per-page read, write, execute permissions
  • W^X where compatible
  • stack guard regions
  • ASLR roadmap for kernel and user space

5.5 Cache and page-cache management

The system shall distinguish:

  • CPU caches, managed indirectly through allocator locality and coherence rules
  • kernel object caches for frequent fixed-size allocations
  • unified page cache for file-backed data where feasible

The page cache must integrate with:

  • filesystem services
  • memory-mapped file objects
  • invalidation on write-back and truncation

6. File System and Storage

6.1 VFS

The VFS shall provide:

  • path resolution
  • mount namespace handling
  • file and directory object abstraction
  • permission and policy hooks
  • cache integration

6.2 Native filesystem

The native filesystem, currently referred to as AuroraFS, shall target:

  • copy-on-write metadata updates
  • metadata checksums
  • optional data checksums
  • extent-based allocation
  • snapshot-ready design

Integrity model:

  • metadata integrity is mandatory
  • crash consistency must be provided by transactional or journaling-equivalent mechanisms

6.3 Storage stack

The storage architecture shall separate:

  • block-device interfaces
  • storage driver hosts
  • filesystem services
  • cache and memory-object integration

6.4 Foreign filesystem support

Planned interoperability path:

  • FAT32 or exFAT for boot media and removable storage
  • ext4 read-first support
  • NTFS read support in later phases
  • APFS deferred due to complexity and ecosystem constraints

Foreign filesystem write support requires explicit validation and must not be enabled based only on parser completeness.

7. Security Model

7.1 Security domains

The security model distinguishes:

  • kernel core
  • privileged services
  • standard services
  • compatibility runtimes
  • sandboxed applications
  • guest-backed workloads

7.2 Access control

The baseline mechanism is rights-controlled handle access.

Each handle contains:

  • object type
  • rights mask
  • ownership or provenance metadata as needed

On top of handle rights, the system shall support a mandatory policy layer governing:

  • filesystem access
  • device access
  • network permissions
  • process interaction
  • compatibility-domain restrictions

7.3 Authentication and identity

Early phases require local identities for:

  • users
  • system services
  • sandbox principals

Enterprise identity, network authentication, and federated login are out of scope for the first implementation phases.

7.4 Sandboxing

Sandbox policy shall be able to restrict:

  • visible filesystem roots
  • device classes
  • outbound and inbound network access
  • IPC peers
  • executable memory permissions

Compatibility runtimes must execute inside explicit compatibility domains with separate policy envelopes.

7.5 Capability and MAC direction

The preferred model is hybrid:

  • capability-like handle rights for concrete object access
  • MAC-like policy decisions for cross-cutting rules

This combines explicit kernel object authorization with system-wide policy control.

8. Networking Stack

8.1 Stack scope

The initial stack supports standard host networking for native and compatibility workloads.

Layer responsibilities:

  • link layer integration through drivers
  • IP routing and packet processing
  • transport protocols
  • socket API mediation
  • control-plane configuration services

8.2 Required protocol support

Phase-ordered support:

  • Ethernet
  • IPv4
  • IPv6
  • ARP and neighbor discovery
  • ICMP
  • UDP
  • TCP

Later candidates:

  • firewalling and traffic policy
  • TLS assist hooks
  • QUIC-friendly APIs
  • virtual switching and namespace-aware networking

8.3 Socket model

The native socket model shall support:

  • blocking and non-blocking operation
  • event-driven readiness
  • compatibility translation for POSIX and Winsock semantics
  • brokered raw-socket access for privileged use only

9. Driver Architecture

9.1 Driver model

Default rule: drivers run in user-space driver hosts.

Exception rule: a driver may run in privileged space only if it is:

  • required before user-space services are available
  • in a path that cannot tolerate user-space round-trips
  • too tightly coupled to interrupt-time mechanisms

9.2 Driver manager

The device manager service shall handle:

  • device discovery
  • driver matching
  • driver host spawning
  • lifecycle supervision
  • crash and restart policy

9.3 Dynamic loading and safety

Driver loading must be:

  • explicit and policy-controlled
  • version-checked against published driver-service contracts
  • restartable for user-space hosted drivers

Direct unrestricted DMA or MMIO access must not be handed to drivers without mediation and auditing support.

10. User Space

10.1 Core services

Mandatory early services:

  • init
  • servicemgr
  • devmgr
  • filesystem service
  • network service
  • security policy service
  • logging or tracing collector

10.2 System libraries and APIs

The user-space API stack shall include:

  • a native C runtime
  • a system library exposing the native kernel API
  • optional C++ support for higher-level services

The native API is the only stable first-party contract. POSIX-like, Win32-like, or Darwin-like behavior belongs in compatibility layers.

10.3 Runtime environments

Supported runtime categories:

  • native VibeOS runtime
  • Linux compatibility runtime
  • Windows subsystem runtime
  • Darwin-oriented compatibility or guest-assist runtime

Compatibility runtimes must be separately versioned and testable.

11. Boot Process

11.1 Boot sequence

UEFI
  -> bootloader
  -> kernel image load
  -> early memory bootstrap
  -> trap and timer setup
  -> virtual memory enablement
  -> SMP startup
  -> init process
  -> service manager
  -> core services
  -> compatibility services

11.2 Bootloader responsibilities

The bootloader shall:

  • load the kernel image
  • provide a structured boot information block
  • pass memory map and firmware tables
  • hand over framebuffer or console information
  • optionally verify images in secure boot configurations

11.3 Kernel initialization phases

  1. early architecture setup
  2. early allocation and memory-map parsing
  3. interrupt and exception descriptor setup
  4. scheduler bootstrap
  5. SMP processor bring-up
  6. transition to full allocators
  7. launch first user-space process

12. Observability

12.1 Logging

Mandatory early logging channels:

  • serial console
  • in-memory kernel log buffer

Later channels:

  • structured event stream
  • persistent diagnostic storage

12.2 Tracing

The system shall provide tracepoints for:

  • scheduler events
  • IPC latency
  • page faults and mapping changes
  • driver and service failures
  • compatibility runtime translation faults

12.3 Debugging

Required debugging capabilities:

  • symbolized panic output in debug builds
  • emulator-friendly debug attachment
  • kernel assertions
  • crash signature capture

13. Scalability and Performance

13.1 Multicore strategy

The architecture targets SMP from the beginning.

Mandatory properties:

  • per-CPU run queues
  • per-CPU statistics and local data where beneficial
  • minimal global scheduler locks
  • inter-processor interrupt support for wake-ups and TLB shootdowns

13.2 Optimization strategy

The performance plan prioritizes:

  • short syscall paths
  • shared-memory-assisted IPC
  • lock partitioning
  • reduced-copy I/O for large transfers
  • locality-aware allocation

13.3 Scalability envelope

The first implementation targets developer and workstation-class systems. The architecture should remain extensible toward:

  • higher core counts
  • NUMA-aware scheduling and allocation
  • container-heavy workloads
  • mixed native and compatibility-domain concurrency

14. Modularity and Extensibility

14.1 Module boundaries

Subsystem boundaries are defined by:

  • stable kernel-internal interfaces for core modules
  • versioned IPC contracts for user-space services
  • explicit compatibility-runtime interfaces

14.2 Plugin and module policy

VibeOS is extensible, but unrestricted in-kernel plugin models are not a design goal.

Preferred extension mechanisms:

  • user-space services
  • driver hosts
  • compatibility runtime modules
  • versioned user-space libraries

Kernel modules are permitted only under the placement policy defined earlier.

14.3 API and ABI versioning

Versioning rules:

  • native kernel ABI is versioned
  • service protocols are versioned independently
  • compatibility runtimes track their own supported foreign ABI surface
  • breaking changes require compatibility windows or migration tooling

15. Architecture Decisions Requiring Ongoing Validation

The following decisions are accepted for now but must be revalidated during implementation:

  • whether specific storage or network fast paths remain out of kernel space
  • whether the first native filesystem uses transactional metadata, journaling, or a hybrid integrity model
  • how much Linux compatibility can be implemented through translation before requiring guest-backed execution for practicality
  • whether selected Windows subsystem pieces need privileged acceleration

16. Open Questions / Missing Requirements

  • What is the primary product target for the first usable release: developer workstation, headless server, embedded appliance, or research platform?
  • Is a GUI stack required before the first public milestone, or is a command-line-only release acceptable?
  • What security posture is mandatory for version 1: secure boot optional, recommended, or required?
  • Is disk encryption in scope for the first storage milestone?
  • What hardware profile defines the minimum supported machine: RAM floor, CPU feature floor, storage type, and network assumptions?
  • Is realtime scheduling a firm requirement or only a future extensibility goal?
  • What degree of POSIX conformance is required for Linux compatibility milestones?
  • What classes of Windows software matter first: console tools, services, GUI applications, or specific enterprise workloads?
  • Is any level of unmodified macOS application support actually required, or is Darwin-adjacent developer-tool portability sufficient?
  • What legal and distribution constraints will govern compatibility work, especially for macOS-adjacent behavior and foreign binary testing?
  • Is hypervisor support required in the base OS, or is lightweight guest execution allowed to depend on a later subsystem?
  • What persistence and recovery guarantees are required for the first native filesystem milestone?
  • Are hot-plug, suspend/resume, and power-management features required in the initial hardware support envelope?
  • Which observability outputs are mandatory in CI: serial logs only, trace artifacts, crash dumps, or performance counters?
  • What formal stability promise, if any, should be made for the native syscall ABI before version 1?

Boot System

Goals

  • deterministic early boot path
  • support for modern firmware on x86_64
  • clear separation between bootloader responsibilities and kernel initialization
  • smooth future transition to ARM64 platform bring-up

Boot model

Phase 0: firmware entry

Initial target firmware is UEFI. Legacy BIOS support is intentionally deferred to reduce complexity in early milestones.

Phase 1: project bootloader

The project will eventually provide a custom bootloader with the following responsibilities:

  • load kernel image and core modules
  • collect memory map and firmware tables
  • establish framebuffer and console handoff data
  • load initial ramdisk or boot package
  • verify image signatures in secure boot configurations
  • pass structured boot information to the kernel

During bring-up, a simpler standards-compliant boot path may be used temporarily if it does not compromise the from-scratch kernel requirement.

Recommended staging:

  • early bring-up may target a well-defined external boot protocol to shorten time-to-first-kernel
  • the long-term architecture still assumes a project-owned bootloader and boot information contract
  • no existing kernel code is reused; only the boot handoff convention may be borrowed temporarily

Phase 2: kernel early init

  • establish early serial and framebuffer logging
  • parse boot information structure
  • initialize physical memory manager
  • create early page tables and higher-half kernel mapping
  • enable interrupts after core descriptor tables are ready
  • bring up bootstrap processor services

Phase 3: SMP and service handoff

  • discover and start application processors
  • initialize scheduler on all cores
  • start init service and system service manager
  • transition from early allocators to full memory subsystem

Early memory strategy

Boot allocators

  • bump allocator for pre-paging early boot
  • early page allocator for initial mappings
  • handoff to full physical frame allocator once memory regions are classified

Address space model

  • higher-half kernel mapping
  • direct physical map for kernel internal memory operations
  • per-process user address spaces with guarded kernel/user split

Boot data contract

The bootloader passes a versioned boot information block containing:

  • memory map
  • CPU and topology data
  • ACPI or equivalent firmware tables
  • framebuffer information
  • boot medium metadata
  • init package descriptors
  • security and measured boot state

Risks and mitigation

  • firmware diversity risk is mitigated by targeting UEFI first
  • early memory corruption risk is reduced through strict phase separation
  • boot ABI drift is controlled through versioned handoff structures

Current implementation snapshot

  • bootloader stub exposes boot-info validation and memory summary helpers.
  • boot-info contract checks now reject zero-length memory regions and invalid base contracts before kernel bring-up.
  • kernel bootstrap initializes security-audit state during early core initialization.
  • bootloader helpers now expose region-type counters and overlap detection for memory-map sanity checks.
  • sanitized boot-info builder now normalizes memory maps (supported-type filtering, base-order sort, adjacent-merge by type) before kernel handoff.
  • boot contract now provides max physical address helper to support early direct-map sizing decisions.
  • boot handoff metadata helpers now cover firmware table pointers (ACPI/SMBIOS), initrd boundaries, and framebuffer geometry with strict validation.
  • boot validation now enforces memory-map placement rules for firmware pointers, initrd ranges, and framebuffer regions to prevent invalid handoff layouts.
  • bootloader can now extract firmware tags into handoff fields/flags (secure boot, measured boot) and produce a PE32+ load plan for UEFI-style kernel image mapping.

Kernel Design

Design intent, written before the code. What is actually built, and how, is in core/ and implementation_progress/kernel_core.md.

Kernel scope

The kernel core is intentionally small and responsible only for primitives that are unsafe or inefficient to place in user space.

Core responsibilities

  • interrupt and exception handling
  • low-level memory management
  • scheduling and dispatch
  • process and thread lifecycle primitives
  • inter-process communication primitives
  • synchronization objects
  • timekeeping and timers
  • capability-aware object management
  • architecture and platform abstraction

Object model

Every kernel-visible resource is represented as a typed object:

  • process
  • thread
  • address space
  • channel
  • shared memory object
  • event
  • timer
  • device endpoint
  • job or resource domain

Objects are referenced through handles. Handles carry rights masks and can be transferred in controlled ways through IPC.

Current runtime prototype updates:

  • handle tables support configurable per-table quota limits.
  • handle allocation failure counters are tracked for observability.
  • object-type counters are available for process/thread handle accounting.
  • handle lifecycle hooks are available for alloc/close/revoke event instrumentation and cleanup orchestration.

Process model

Process

A process owns:

  • one address space
  • one handle table
  • resource accounting state
  • security token and capability set
  • one or more threads

Thread

A thread owns:

  • register state
  • scheduling attributes
  • signal or exception delivery state
  • thread-local storage metadata

IPC model

IPC is central to the system design.

See also: docs/ipc.md for the dedicated transport and contract strategy.

Requirements

  • low overhead for local RPC-style service calls
  • secure handle passing
  • async messaging for service-oriented architecture
  • support for compatibility runtimes that need brokering

Mechanisms

  • message channels with bounded queues
  • shared-memory regions for bulk data
  • event signaling and wait sets
  • optional zero-copy path for large transfers where mappings permit

Kernel module policy

Kernel modules are permitted only when they satisfy at least one of these conditions:

  • required during early boot
  • latency-sensitive enough to justify privileged execution
  • too tightly coupled to interrupt-time hardware handling

All other subsystems should prefer user-space services.

Failure containment

  • drivers and filesystems should restart independently when hosted out of kernel
  • kernel panics should preserve diagnosable crash data
  • watchdog supervision should detect failed core services

Observability

The kernel should expose:

  • structured tracepoints
  • per-CPU scheduling statistics
  • memory and IPC counters
  • fault reporting interfaces
  • boot and runtime diagnostics suitable for automated testing

Current runtime prototype status

  • interrupt controller runtime now supports IRQ masking/unmasking and global enable/disable gates.
  • timer runtime includes tick-to-time conversion helpers, deadline arming/expiry checks, and IRQ-backend binding semantics.
  • process security model includes token capabilities, security labels, and policy-driven interaction checks.

Memory Management

Design intent, written before the code. What is actually built, and how, is in mm/ and implementation_progress/memory_manager.md.

Goals

  • strong isolation between protection domains
  • predictable page fault and mapping behavior
  • scalable memory allocation on multicore systems
  • support for compatibility runtimes with different memory semantics

Physical memory management

Early phase

  • firmware-provided memory map ingestion
  • boot-time region classification
  • early frame allocator for bootstrap use

Runtime phase

  • buddy allocator or segmented frame allocator for general page allocation
  • separate pools for DMA-capable memory when required
  • accounting by zone and future NUMA node

Virtual memory model

Kernel space

  • higher-half kernel mapping
  • direct map for physical memory access
  • guard pages around critical regions

User space

  • per-process address spaces
  • demand paging support
  • copy-on-write support for process creation and memory sharing
  • mapped objects for files, anonymous memory, and shared memory

Memory objects

All mappings are backed by explicit memory objects:

  • anonymous memory object
  • file-backed memory object
  • shared memory object
  • device memory object

This simplifies accounting, rights management, and compatibility runtime integration.

Kernel heap

The kernel heap should be layered:

  • early bump allocator
  • slab or object caches for fixed-size kernel objects
  • general-purpose heap for variable allocations

The design should favor cache locality and deterministic behavior for hot object types.

Userland allocation support

User processes receive kernel primitives for:

  • reserving virtual address space
  • committing and decommitting pages
  • protection changes
  • shared memory mapping
  • memory-mapped file creation

These primitives are sufficient to emulate Linux mmap, Windows virtual memory APIs, and selected Darwin allocation behavior in user-space compatibility layers.

Hardening

  • W^X enforcement where compatible
  • ASLR for kernel and user spaces as platform maturity allows
  • stack guards
  • guarded pools for sensitive allocations
  • optional page poisoning and quarantine in debug builds

Future enhancements

  • transparent huge pages where beneficial
  • compressed memory experiments
  • NUMA-aware placement
  • confidential-computing-friendly abstractions

Current implementation snapshot

  • VM runtime now supports partial unmap (unmap_range) with map splitting semantics.
  • VM observability helpers expose total map count and total mapped bytes.
  • read-only clone support is available (clone_readonly) to model early copy-on-write style address-space duplication.
  • on x86-64 this is no longer a model: fork is real copy-on-write, with the writable bit cleared, PTE_COW set and a frame refcount table deciding when a page can be freed. The three places that have to agree about it - the fault handler accepting kernel-mode writes, hw_user_range_ok treating a COW page as writable, and a second fork preserving the bit - are described in docs/process_semantics.md.
  • VM integrity helpers now include address-space validation (validate) and adjacent-map compaction (compact) for safer and denser map layouts.
  • PMM bootstrap can now select the best usable region from boot memory maps while avoiding initrd overlap, instead of assuming a fixed first-region layout.

Scheduler

Design intent, written before the code. What is actually built, and how, is in sched/ and implementation_progress/sched.md.

Goals

  • low wake-up latency
  • fairness under mixed interactive and background workloads
  • multicore scalability
  • affinity and topology awareness
  • support for real-time and near-real-time classes in later phases

Scheduling model

VibeOS uses a preemptive priority-based scheduler with fair scheduling within priority bands.

Thread classes

  • realtime reserved for strict bounded-latency system tasks in later phases
  • interactive for UI and latency-sensitive applications
  • normal for standard user workloads
  • background for maintenance and batch work

Core design

Per-CPU run queues

Each CPU maintains a local run queue to reduce lock contention and cache disruption.

Load balancing

Periodic and event-driven balancing migrates threads across CPUs based on:

  • CPU utilization
  • thread affinity
  • cache locality hints
  • NUMA domain awareness in future phases

Priority handling

  • dynamic boosts for wake-up responsiveness
  • starvation prevention through aging
  • priority ceilings for specific kernel-mediated synchronization paths when necessary

Timeslice policy

  • interactive threads receive shorter slices with stronger latency preference
  • throughput-oriented threads receive longer slices
  • kernel workers should avoid monopolizing CPU time by default

Multicore strategy

  • minimal global scheduler state
  • per-CPU timer events where possible
  • explicit handling for idle CPUs and wake-up targets
  • future support for heterogeneous core topologies

Roadmap

Early implementation

  • fixed priority bands
  • round-robin within queues
  • basic SMP balancing

Mid-stage evolution

  • fair scheduling weights
  • better I/O wake heuristics
  • CPU topology awareness

Advanced stage

  • real-time scheduling class
  • deadline scheduling for selected workloads
  • energy-aware placement for mobile-class platforms

Current runtime prototype status

  • class-based default timeslice selection is implemented (background=8, normal=4, interactive=2, realtime=1).
  • enqueue paths normalize zero-timeslice threads to class defaults.
  • per-CPU and aggregate scheduler counters remain available through runtime telemetry helpers and syscall observability paths.
  • runqueue aging helpers are available (age_cpu, age_all) to apply bounded timeslice boosts and reduce starvation risk.
  • the x86-64 runtime runs preemptively on four cores, and the boot gate asserts all four are online rather than assuming it. Two cores ending up on one task and a lost TLS base both reproduced at roughly one boot in three, which is why scheduler changes here are checked with scripts/dev/repeat-boot.sh and not with a single green run.

System Call Interface

Design intent, written before the code. What is actually built, and how, is in abi/ and implementation_progress/system_call_interface.md.

Design goals

  • compact, stable, orthogonal kernel API
  • low overhead on hot paths
  • minimal policy in the syscall surface
  • compatibility enablement without kernel ABI explosion

Design philosophy

VibeOS does not expose Linux, Win32, or Darwin syscalls directly as the native kernel ABI. Instead, it provides a smaller native syscall set centered on:

  • process and thread management
  • virtual memory operations
  • IPC and synchronization
  • time and timers
  • object and handle management
  • device and I/O primitives
  • security token and capability operations

Compatibility layers translate foreign application behavior into this native model.

Interface style

  • handle-based operations rather than global integer descriptors where possible
  • explicit rights checks at object boundaries
  • structured error codes with deterministic semantics
  • capability negotiation for optional features

ABI v0 argument mapping (frozen baseline)

vibeos_syscall_frame_t keeps the fixed layout (id, arg0, arg1, arg2, result).

Argument semantics for current syscall groups:

Syscallarg0arg1arg2
HANDLE_ALLOCrights maskreserved (0)caller pid (0 = kernel context)
HANDLE_CLOSEhandle idreserved (0)caller pid (0 = kernel context)
EVENT_SIGNALevent handlereserved (0)reserved (0)
WAITSET_ADD_EVENTevent handlewaitset owner pidcaller pid
WAITSET_STATS_GETreserved (0)reserved (0)caller pid (0 = kernel or waitset owner)
WAITSET_STATS_EXT_GETreserved (0)reserved (0)caller pid (0 = kernel or waitset owner)
WAITSET_WAKE_POLICY_SETwake policy enum (0 FIFO, 1 REVERSE)reserved (0)caller pid (0 = kernel or waitset owner)
WAITSET_WAKE_POLICY_GETreserved (0)reserved (0)caller pid (0 = kernel or waitset owner)
WAITSET_STATS_RESETreserved (0)reserved (0)caller pid (0 = kernel or waitset owner)
WAITSET_OWNER_GETreserved (0)reserved (0)caller pid (0 = kernel or waitset owner)
WAITSET_SNAPSHOT_GETreserved (0)reserved (0)caller pid (0 = kernel or waitset owner)
PROCESS_SPAWNparent pidreserved (0)caller pid (0 = kernel context)
PROCESS_STATE_GETtarget pidreserved (0)caller pid (0 = kernel; otherwise self or directly related process)
PROCESS_STATE_SETtarget pidtarget process state enumcaller pid (0 = kernel; otherwise self only)
PROCESS_TERMINATEtarget pidreserved (0)caller pid (0 = kernel; otherwise self only)
PROCESS_COUNT_GETreserved (0)reserved (0)reserved (0)
THREAD_COUNT_GETreserved (0)reserved (0)reserved (0)
PROCESS_LIVE_COUNT_GETreserved (0)reserved (0)reserved (0)
PROCESS_TERMINATED_COUNT_GETreserved (0)reserved (0)reserved (0)
PROCESS_STATE_COUNT_GETprocess state enumreserved (0)reserved (0)
PROCESS_STATE_SUMMARY_GETreserved (0)reserved (0)reserved (0)
PROC_TRANSITION_COUNTERS_GETreserved (0)reserved (0)reserved (0)
PROC_TRANSITION_COUNTERS_RESETreserved (0)reserved (0)caller pid (0 required)
THREAD_CREATEpidreserved (0)reserved (0)
THREAD_STATE_GETtidreserved (0)caller pid (0 = kernel; otherwise owner pid only)
THREAD_STATE_SETtidtarget state enumcaller pid (0 = kernel; otherwise owner pid only)
THREAD_EXITtidreserved (0)caller pid (0 = kernel; otherwise owner pid only)
THREAD_STATE_COUNT_GETthread state enumreserved (0)reserved (0)
THREAD_STATE_SUMMARY_GETreserved (0)reserved (0)reserved (0)
VM_MAPvapalen
VM_UNMAPvalenreserved (0)
VM_PROTECTvalenperms
PROC_AUDIT_COUNTreserved (0)reserved (0)caller pid (0 = kernel/global view)
PROC_AUDIT_GETaudit index (caller-local if arg2 != 0)out: successcaller pid input, then out: revoked_count
PROC_AUDIT_POLICY_SETretention policy (0 overwrite-oldest, 1 drop-newest)reserved (0)caller pid (0 required)
PROC_AUDIT_POLICY_GETreserved (0)reserved (0)caller pid (0 required)
PROC_AUDIT_DROPPEDreserved (0)reserved (0)caller pid (0 required)
SCHED_RUNNABLE_GETreserved (0)reserved (0)reserved (0)
SCHED_RUNQUEUE_DEPTH_GETcpu idreserved (0)reserved (0)
SCHED_CPU_COUNT_GETreserved (0)reserved (0)reserved (0)
SCHED_PREEMPTIONS_GETcpu idreserved (0)reserved (0)
SCHED_WAIT_TIMEOUTS_GETcpu idreserved (0)reserved (0)
SCHED_WAIT_WAKES_GETcpu idreserved (0)reserved (0)
SCHED_PREEMPTIONS_TOTAL_GETreserved (0)reserved (0)reserved (0)
SCHED_WAIT_TIMEOUTS_TOTAL_GETreserved (0)reserved (0)reserved (0)
SCHED_WAIT_WAKES_TOTAL_GETreserved (0)reserved (0)reserved (0)
SCHED_COUNTER_SUMMARY_GETreserved (0)reserved (0)reserved (0)
SCHED_COUNTERS_RESETreserved (0)reserved (0)caller pid (0 required)

| PROC_AUDIT_COUNT_ACTION | action enum | reserved (0) | caller pid (0 = global, non-zero = caller-scoped) | | PROC_AUDIT_COUNT_SUCCESS | success value (0/1) | reserved (0) | caller pid (0 = global, non-zero = caller-scoped) | | PROC_AUDIT_SUMMARY | reserved (0) | reserved (0) | caller pid (0 = global, non-zero = caller-scoped) | | PROCESS_TOKEN_GET | target pid | reserved (0) | caller pid (0 = kernel; otherwise self or directly related process) | | PROCESS_TOKEN_SET | target pid | capability mask | caller pid (0 = kernel; otherwise self only) | | POLICY_CAPABILITY_GET | policy target enum (1 fs-open, 2 net-bind, 3 process-spawn, 4 process-interact override) | reserved (0) | caller pid (advisory, unrestricted read) | | POLICY_CAPABILITY_SET | policy target enum (1 fs-open, 2 net-bind, 3 process-spawn, 4 process-interact override) | required capability bit (0..31) | caller pid (0 required) | | POLICY_SUMMARY_GET | reserved (0) | reserved (0) | caller pid (advisory, unrestricted read) | | SEC_AUDIT_COUNT | reserved (0) | reserved (0) | caller pid (0 = global, non-zero = caller-scoped) | | SEC_AUDIT_GET | event index (caller-local if arg2 != 0) | reserved (0) | caller pid (0 = global, non-zero = caller-scoped) | | SEC_AUDIT_COUNT_ACTION | security-audit action enum | reserved (0) | caller pid (0 = global, non-zero = caller-scoped) | | SEC_AUDIT_COUNT_SUCCESS | success value (0/1) | reserved (0) | caller pid (0 = global, non-zero = caller-scoped) | | SEC_AUDIT_SUMMARY | reserved (0) | reserved (0) | caller pid (0 = global, non-zero = caller-scoped) | | SEC_AUDIT_RESET | reserved (0) | reserved (0) | caller pid (0 required) | | PROCESS_SECURITY_LABEL_GET | target pid | reserved (0) | caller pid (0 = kernel; otherwise self or directly related process) | | PROCESS_SECURITY_LABEL_SET | target pid | security label | caller pid (0 = kernel; otherwise self only) | | PROCESS_INTERACT_CHECK | target pid | reserved (0) | caller pid (!= 0, no kernel shortcut) |

PROC_AUDIT_GET returns:

  • result = event.seq (positive on success)
  • arg0 = event.action
  • arg1 = event.success
  • arg2 = event.revoked_count

PROC_AUDIT_POLICY_GET returns:

  • result = current retention policy enum value.

PROC_AUDIT_DROPPED returns:

  • result = cumulative number of dropped audit events (only when retention mode is DROP_NEWEST).

WAITSET_STATS_GET returns:

  • arg0 = added
  • arg1 = removed
  • arg2 = wait_calls
  • result = wait_timeouts

WAITSET_STATS_EXT_GET returns:

  • arg0 = wait_wakes
  • arg1 = ownership_denials
  • arg2 = owner_pid
  • result = current waitset event count

WAITSET_WAKE_POLICY_GET returns:

  • result = current wake policy enum (0 FIFO, 1 REVERSE).

WAITSET_OWNER_GET returns:

  • result = waitset owner pid
  • arg0 = ownership enforcement flag
  • arg1 = current waitset event count

WAITSET_SNAPSHOT_GET returns:

  • result = waitset owner pid
  • arg0 = ownership enforcement flag
  • arg1 = wake policy enum (0 FIFO, 1 REVERSE)
  • arg2 = current waitset event count

PROCESS_STATE_SUMMARY_GET returns:

  • arg0 = count in NEW
  • arg1 = count in RUNNING
  • arg2 = count in BLOCKED
  • result = count in TERMINATED

THREAD_STATE_SUMMARY_GET returns:

  • arg0 = count in NEW
  • arg1 = count in RUNNABLE
  • arg2 = count in BLOCKED
  • result = count in EXITED

PROC_TRANSITION_COUNTERS_GET returns:

  • arg0 = process-state transition count
  • arg1 = thread-state transition count
  • arg2 = process-termination count
  • result = thread-exit count

SCHED_COUNTER_SUMMARY_GET returns:

  • arg0 = total preemptions
  • arg1 = total wait timeouts
  • arg2 = total wait wakes
  • result = total runnable threads

PROC_AUDIT_SUMMARY returns:

  • arg0 = total visible audit events
  • arg1 = visible successful events
  • arg2 = visible failed events
  • result = 0 on success

PROCESS_TOKEN_GET returns:

  • arg0 = token uid
  • arg1 = token gid
  • arg2 = token capability mask
  • result = 0 on success

POLICY_CAPABILITY_GET returns:

  • result = required capability bit for the requested target

POLICY_SUMMARY_GET returns:

  • arg0 = required capability bit for fs-open policy checks
  • arg1 = required capability bit for net-bind policy checks
  • arg2 = required capability bit for process-spawn policy checks
  • result = required capability bit for process-interact override checks

SEC_AUDIT_GET returns:

  • result = security audit sequence (caller-local redacted sequence when caller_pid != 0)
  • arg0 = action enum (1 process-spawn, 2 process-token-set, 3 policy-capability-set)
  • arg1 = success flag (0/1)
  • arg2 = target pid (0 when not process-targeted)

SEC_AUDIT_SUMMARY returns:

  • arg0 = total visible security audit events
  • arg1 = visible successful events
  • arg2 = visible failed events
  • result = 0 on success

PROCESS_SECURITY_LABEL_GET returns:

  • result = target process security label

PROCESS_INTERACT_CHECK returns:

  • result = 1 if caller is currently allowed to interact with target process according to label and override capability policy, 0 if denied

Access policy:

  • caller_pid == 0: full global audit stream.
  • caller_pid != 0: only events where event.owner_pid == caller_pid; result is redacted to caller-local sequence (index + 1).
  • PROC_AUDIT_POLICY_SET, PROC_AUDIT_POLICY_GET, PROC_AUDIT_DROPPED are kernel-only (caller_pid == 0) in ABI v0.
  • PROCESS_STATE_GET is kernel-only, self-introspection, or directly related process introspection (parent or child).
  • PROCESS_STATE_SET and PROCESS_TERMINATE are kernel-only or self-targeted.
  • PROCESS_TOKEN_GET is kernel-only, self-introspection, or directly related process introspection (parent or child).
  • PROCESS_TOKEN_SET is kernel-only or self-targeted.
  • PROCESS_SECURITY_LABEL_GET is kernel-only, self-introspection, or directly related process introspection (parent or child).
  • PROCESS_SECURITY_LABEL_SET is kernel-only or self-targeted.
  • SEC_AUDIT_COUNT, SEC_AUDIT_GET, SEC_AUDIT_COUNT_ACTION, and SEC_AUDIT_SUMMARY are kernel-global for caller_pid == 0, caller-scoped otherwise.
  • THREAD_STATE_GET, THREAD_STATE_SET, THREAD_EXIT are kernel-only or thread-owner scoped.
  • WAITSET_STATS_GET and WAITSET_STATS_EXT_GET are kernel-only or waitset-owner scoped.
  • WAITSET_WAKE_POLICY_SET, WAITSET_WAKE_POLICY_GET, and WAITSET_STATS_RESET are kernel-only or waitset-owner scoped.
  • WAITSET_OWNER_GET is kernel-only or waitset-owner scoped.
  • WAITSET_SNAPSHOT_GET is kernel-only or waitset-owner scoped.
  • PROC_TRANSITION_COUNTERS_RESET is kernel-only.
  • SCHED_COUNTERS_RESET is kernel-only.
  • POLICY_CAPABILITY_SET is kernel-only.
  • SEC_AUDIT_RESET is kernel-only.

Implementation helpers for ABI v0 are centralized in include/vibeos/syscall_abi.h and should be preferred over direct field writes in kernel tests and user-space glue.

ABI stability strategy

  • native ABI is versioned
  • deprecated calls remain shimmed for defined support windows
  • compatibility runtimes own most foreign ABI churn

Performance considerations

  • vDSO-style user mappings for clock and other safe queries in future phases
  • batched IPC and handle transfer operations
  • minimal-copy I/O design for large transfers

Early syscall groups

  • process
  • thread
  • address_space
  • memory_object
  • channel
  • event
  • timer
  • device
  • debug and trace

The Linux ABI as implemented on metal

The native ABI above is the design. Separately, and in parallel, the x86-64 port serves the Linux system-call ABI directly to ring 3, because that is what lets real programs run before a native userland exists. Entry is the syscall instruction (with int 0x80 kept as a bring-up path); both reach one dispatcher in kernel/arch/x86_64/arch_hw.c.

Two rules govern what goes in it:

  • A syscall either does the thing or returns the error Linux returns. Reporting a success that did not happen makes the program fail later, somewhere unrelated, with nothing pointing back to the kernel. rseq and getrandom return -ENOSYS for exactly this reason: both have mandatory fallbacks, and a fabricated answer is worse than a refusal - particularly for the syscall a program uses to get key material.
  • Unimplemented numbers are named in the serial log, so a missing one is visible the first time it is reached rather than inferred from a crash.

The list below is the switch in vibeos_x86_64_linux_syscall, not an aspiration. Anything not in it is refused with -ENOSYS and named in the log.

GroupCalls
Processfork, clone, execve, wait4, exit, exit_group, getpid, getppid, gettid, prctl, set_tid_address, set_robust_list, setuid, setgid
Signalsrt_sigaction, rt_sigprocmask, rt_sigreturn, kill, tkill, tgkill
Memorybrk, mmap, mprotect, munmap
Filesopen, openat, close, read, write, readv, writev, lseek, fstat, newfstatat, getdents64, getcwd, readlinkat, unlink, mkdir, ioctl
Descriptorspipe, pipe2, dup, dup2
Socketssocket, bind, listen, connect, accept, sendto, recvfrom
Timeclock_gettime, time
Runtime startuparch_prctl, uname, prlimit64, futex, sched_yield, getuid/geteuid/getgid/getegid

Deliberately refused with -ENOSYS: rseq, getrandom, sendfile, and clone when CLONE_VM or CLONE_THREAD is set. Each has a documented caller fallback; a fabricated success would move the failure somewhere unrelated.

Notable semantics:

  • arch_prctl(ARCH_SET_FS) writes MSR_FS_BASE and the value is per task, reloaded on every context switch - thread-local storage is the first thing a C runtime touches, and leaving one task's base loaded for another would let a program read someone else's thread state. A base outside user memory is refused before the write, since a non-canonical value faults in ring 0.
  • mprotect and munmap change the real page-table entries and flush the TLB. MAP_FIXED and file-backed mmap are refused rather than ignored.
  • ioctl returns -ENOTTY. There is no terminal device here, and that is the answer a libc uses to decide stdout should be block buffered. fstat on a standard descriptor agrees with it: a character device that is not a terminal.
  • fork shares pages instead of copying them. Every writable user leaf becomes read-only and copy-on-write in both address spaces, the frame's reference count goes up, and the first write from either side faults and duplicates it. Three details make it work rather than nearly work: the fault handler must accept a fault raised in ring 0, because the kernel writes user buffers with CR0.WP set and read() filling a freshly forked buffer is exactly such a write; hw_user_range_ok must treat a copy-on-write page as writable, or a syscall rejects the buffer before the fault can fix it; and a second fork must recognise an already-COW page rather than filing it under "read-only, so it never needs copying", which would leave it permanently unwritable.
  • Signals are real. rt_sigaction and rt_sigprocmask install dispositions and masks that are honoured; a raise sets a pending bit and wakes the task if it was blocked; delivery happens on the way back to user space, where the process's own stack is available. The frame is built below the 128-byte red zone, 16-byte aligned with room for a return address, and the return address is the C library's SA_RESTORER trampoline - a handler installed without one cannot return anywhere, so the signal takes its default action instead. rt_sigreturn restores the saved frame but forces cs, ss and rflags back to safe values, because that frame lives in memory the program can write and nothing read out of it may decide privilege. A magic word in the frame is checked for the same reason.
  • A Linux sigset_t numbers its bits from zero: bit 0 is signal 1. This kernel numbers them by signal, and converts only in hw_sigset_from_user / hw_sigset_to_user. Getting it wrong shifts every mask by one.
  • SIGKILL and SIGSTOP can be neither caught nor blocked: rt_sigaction returns EINVAL for them and rt_sigprocmask accepts the request and drops the two bits, as Linux does.
  • A wait status from wait4 is not an exit code. The exit code sits in the high byte and a killing signal in the low seven bits; 128 + sig is what a shell prints, not what the kernel stores.
  • pipe2 allocates one buffer and two descriptors, read end first; O_CLOEXEC is accepted and ignored, since there is no close-on-exec list. dup2 onto 0, 1 or 2 records a standard-descriptor redirection, which is how a shell attaches a pipe to a program that knows nothing about it. dup is dup2 onto the lowest free descriptor. Descriptors are inherited across fork with the pipe's reader and writer counts incremented, and released on exit - a pipe stays alive until both ends are gone, so a reader can still drain data after the last writer closed. Writing to a pipe with no readers raises SIGPIPE and returns EPIPE.
  • clone is what a C library actually calls for fork. Flags that mean a thread (CLONE_VM, CLONE_THREAD) return -ENOSYS; anything else forks. tkill and tgkill reduce to kill, since there is one thread per process.
  • getcwd returns / and readlinkat answers only /proc/self/exe, from the path execve was given. Both say what is true rather than inventing a path a program would then fail to open.
  • openat accepts only AT_FDCWD; newfstatat additionally handles an empty path with AT_EMPTY_PATH, and distinguishes a directory from a file by trying to list it, because ls behaves differently for each.
  • setuid/setgid succeed for 0 and return EPERM otherwise. There is one identity here and it is root.
  • time and clock_gettime are derived from the timer tick, not from a real-time clock.

How much of this a gate defends

The boot gate (scripts/qemu-cli-smoke-linux.py) drives these paths through real programs rather than a test written to agree with the kernel. It fails on signal_delivery_broken if a program built by musl-gcc against a real libc (tests/linux/musl_signal.c, staged as SIGNAL.ELF) does not print SIG_OK after exercising handlers, masking, ignoring and default actions - the trampoline a handler returns through comes from that libc, not from a test that agrees with the kernel; on pipeline_did_not_complete if ls /EFI/BOOT | wc -l in BusyBox's shell does not get as far as the PIPE_OK that follows it; and on busybox_file_operations_failed if openat, fstat, read or getdents64 regress, since those are BusyBox's own error messages.

Copy-on-write is reported, not asserted: the kernel prints [MM] COW_STATS shared=... copied=... at the end of the self-test - 1221 and 24 on the boot this was written against - but no invariant checks the numbers. What the gate defends is that forking and exec'ing programs keep working.

Compatibility note

Linux compatibility may expose a Linux-like user ABI inside its subsystem, but translation happens above the native kernel API unless a measured exception is justified.

IPC Design

Why IPC is central

VibeOS depends on IPC not only for ordinary application communication but also for core system structure. Drivers, filesystems, compatibility layers, policy services, and service supervision all rely on IPC as the primary contract surface between protection domains.

Goals

  • low latency for request-response service calls
  • secure transfer of handles and shared memory references
  • bounded memory usage under load
  • clean support for async and sync communication
  • debuggable and versionable service contracts

Primitive set

Channels

Bidirectional or paired message channels are the default control-path primitive.

Properties:

  • message boundaries preserved
  • kernel-validated handle passing
  • optional reply capability for RPC-style interactions
  • cancellation-aware waiting

Shared memory

Shared memory complements channels for bulk transfer.

Use cases:

  • filesystem I/O buffers
  • graphics surfaces
  • network packet rings
  • compatibility runtime staging buffers

Events and wait sets

Events allow low-cost signaling. Wait sets allow a service to block on multiple channels, timers, and events without ad hoc polling loops.

Current runtime notes:

  • wait sets support FIFO, REVERSE, and ROUND_ROBIN wake policy modes.
  • round-robin wake policy keeps a rotating cursor to improve fairness under sustained multi-event contention.

Contract model

  • every long-lived system service should publish a versioned IPC contract
  • contracts should be documented separately from implementation code
  • incompatible protocol changes require explicit version negotiation or side-by-side compatibility
  • security-sensitive services should prefer brokered narrow interfaces over generic escape hatches

Performance strategy

  • fast path for small fixed-size messages
  • shared-memory-assisted transfer for large payloads
  • per-CPU delivery queues where appropriate
  • backpressure on overloaded receivers
  • tracing hooks to measure queue depth, latency, and copy counts

Security strategy

  • sender identity and rights checked at receive boundary
  • no ambient authority through channel inheritance by default
  • handle transfer allowed only with explicit rights masks
  • broker services mediate privileged operations for sandboxed runtimes

Early implementation scope

  • local same-machine IPC only
  • synchronous request-response and asynchronous notification
  • handle passing
  • wait sets and timeout support

Distributed IPC or transparent remote IPC is explicitly out of scope for early phases.

Filesystem

Design intent, written before the code. What is actually built, and how, is in io/ and implementation_progress/filesystem_layer.md.

The production-oriented namespace, directory tree, mount policy, compatibility mapping and staged migration are specified in filesystem-design/. That design does not change the current boot layout or mark the storage layer complete; it deliberately separates the target hierarchy from the FAT-root bring-up environment.

Goals

  • modern reliability and integrity model
  • efficient metadata operations
  • support for snapshots and crash recovery in future phases
  • clean separation between VFS and concrete filesystem implementations

Architecture

Virtual filesystem layer

The VFS provides:

  • path resolution
  • mount management
  • file handle abstraction
  • permission and policy hooks
  • filesystem-neutral caching interfaces

Filesystem hosting model

The preferred architecture is:

  • VFS and page-cache integration in privileged space or tightly controlled kernel services
  • concrete filesystems hosted as isolated services when practical

This allows experimentation without placing all on-disk parsing logic in the kernel.

Native filesystem direction

The native filesystem, tentatively named AuroraFS, should target:

  • copy-on-write metadata updates
  • checksummed metadata
  • optional checksummed data
  • extent-based allocation
  • snapshot support
  • efficient small-file performance

Compatibility strategy

Where this now stands

Five filesystems are implemented, each behind the same vibeos_fs_ops_t, so nothing above the mount layer names any of them:

FilesystemAccess
FAT16/FAT32read/write - the boot volume
ext2read, including triple-indirect blocks and holes
ISO9660read, including Joliet names
exFATread
NTFSread - fixups, resident and non-resident attributes, run lists

MBR and GPT partition tables are parsed. ext4 and APFS remain unimplemented; ext2 was built first because it is the same shape of problem with a fraction of the surface, and it is what proved the interface could hold a second filesystem at all.

Full write support for foreign filesystems stays gated behind test coverage, and the bar here is a sabotage case per guard: removing any single check must turn a named test red.

Caching model

  • unified page cache where feasible
  • write-back with journalling or transaction semantics for supported filesystems. The block cache is write-back today and its flush reaches the device's own cache, which is what makes a barrier real rather than nominal.
  • explicit invalidation contracts for compatibility runtimes and VM-backed file access

Security considerations

  • namespace-aware mount permissions
  • immutable and verified system partitions in hardened modes
  • per-filesystem policy hooks
  • brokered access for sandboxed applications

Current implementation snapshot

  • VFS runtime supports mount, unmount, open, close, and secure-open policy checks.
  • mount observability is available through active-mount counters.
  • unmounted filesystems are rejected by open paths, improving lifecycle correctness.
  • the kernel path is block device -> block cache -> filesystem driver -> mount, with the syscall layer naming none of the five drivers.
  • a write-ahead journal recovers at attach and is swept against power loss, but no driver stages its metadata through it yet: the mechanism is verified, the volume is not. See docs/implementation_progress/filesystem_layer.md.

Networking Stack

Goals

  • modern TCP/IP capability
  • modular protocol evolution
  • low latency and good multicore throughput
  • clean socket semantics for native and compatibility applications

Stack structure

Kernel responsibilities

  • packet ingress or egress primitives
  • network buffer management fast path
  • interface event delivery
  • low-level filtering hooks

User-space or service responsibilities

  • control plane
  • configuration management
  • policy enforcement
  • advanced routing
  • firewall management
  • network namespaces or tenant policies in future phases

Protocol roadmap

Early support

  • Ethernet
  • IPv4
  • IPv6
  • ARP or neighbor discovery
  • ICMP
  • UDP
  • TCP

Later support

  • TLS acceleration hooks
  • QUIC-friendly interfaces
  • virtual switching
  • container networking

Socket model

The native socket layer should be expressive enough to support:

  • POSIX-like compatibility
  • Windows socket compatibility through subsystem translation
  • event-driven high-performance servers

Current runtime prototype status

  • vibeos_socket_create, vibeos_socket_bind, vibeos_socket_send, and vibeos_socket_close are available in the host test runtime.
  • vibeos_socket_bind_secure enforces policy-gated bind authorization using security-token capabilities (net_bind_required_capability_bit).
  • Policy capability updates are now runtime-configurable through syscall control paths (POLICY_CAPABILITY_SET for kernel callers).
  • receive-side simulation (vibeos_socket_receive) and aggregate runtime socket statistics are available for observability tests.
  • runtime bind path rejects port collisions across active sockets.

Performance direction

  • per-CPU queues
  • RSS-friendly receive flow steering
  • zero-copy or reduced-copy buffers where safe
  • future support for polling rings or submission queues if justified

Security

  • namespace-aware network access
  • brokered raw socket access
  • firewall and policy hooks
  • sandbox integration for compatibility runtimes

Device Drivers

Design intent, written before the code. What is actually built, and how, is in devices/ and implementation_progress/driver_framework.md.

Driver model goals

  • isolate faults from the kernel whenever practical
  • standardize device discovery and lifecycle
  • support high-performance paths for networking and storage
  • keep hardware-specific logic out of generic subsystems

Driver architecture

Driver manager

A dedicated driver manager service is responsible for:

  • device enumeration
  • driver matching and loading
  • lifecycle supervision
  • permission brokerage for driver-host processes

Driver hosts

Preferred model:

  • user-space driver hosts for most devices
  • specialized privileged drivers only for boot-critical or ultra-low-latency hardware paths

Device classes

  • storage
  • network
  • display
  • input
  • USB and bus controllers
  • audio
  • virtual devices

Hardware abstraction

The kernel exposes safe primitives for:

  • interrupt delivery
  • MMIO mapping
  • DMA setup through mediated interfaces
  • device memory objects
  • event notification

This keeps the kernel in control of trust boundaries even when drivers live in user space.

Initial priorities

  • APIC or interrupt controller support
  • timer devices
  • serial console
  • framebuffer or simple display output
  • AHCI or NVMe storage
  • PCIe enumeration
  • basic network interface

Reliability model

  • supervised restart for user-space drivers
  • versioned driver-service protocols
  • structured fault and telemetry reporting

Current implementation snapshot

  • driver framework now enforces unique driver registration IDs.
  • runtime supports driver unregister and driver-state queries for service health checks.
  • driver-host probe path remains service-state aware and integrates with device discovery counters.

Graphics

This file records the existing framebuffer desktop and its current verification limits. The target desktop/window-system architecture, visual language, UX requirements and implementation stages are specified in ui-design/. The target is not yet implemented merely because the current kernel desktop draws a terminal window.

What exists on screen, how it gets there, and - the part worth stating plainly

  • how little of it any gate would catch if it broke.

The path

The bootloader asks UEFI's Graphics Output Protocol for a linear framebuffer and passes its address, dimensions, pitch and pixel format to the kernel in the boot info structure. Nothing later re-negotiates the mode: the resolution the firmware chose is the resolution VibeOS runs at, for the whole boot.

Everything above that is software drawing into that one buffer. There is no GPU driver, no acceleration and no second buffer. A pixel is written by computing its address from the pitch and storing to it.

The desktop is composed in the order a painter would: background, then window frames, then their contents, then the pointer last so it is never covered by what was drawn after it. Redraw is by region rather than whole-screen where the region is known, because at these resolutions a full repaint per mouse move is visible as a smear.

Input

Two PS/2 devices, both on the legacy controller:

  • Keyboard, IRQ1. Scan codes are translated in the handler and pushed into a ring buffer that read() drains. The ring holds 2048 bytes; it held 512 until a paste-sized burst of input during a BusyBox pipeline overran it and dropped characters, which looked like the shell mis-parsing its input rather than like a full buffer.
  • Mouse, IRQ12. Three-byte packets carrying button state and signed X/Y deltas. Y is inverted relative to screen coordinates, and the deltas are sign-extended out of a flags byte rather than being signed on the wire, which is the detail that turns "the pointer only moves down and right" into a working pointer.

The on-screen terminal

The desktop hosts a terminal window that shares the shell with the serial console rather than running a second one. It is a view onto the same session: the same ring buffer, the same process. A character typed on the PS/2 keyboard and a character arriving on the serial line are the same event by the time anything above the driver sees them.

What is verified, and what is not

This is the honest part. The GUI is not gated.

The boot smoke test asserts state rather than markers - a DHCP lease, four cores online, a TCP round trip, a Linux binary that ran - and it can assert that the graphics stack initialised and that the desktop reports itself ready, because those are serial-visible. It cannot assert that anything appeared. A framebuffer full of black pixels and a correctly drawn desktop produce the same serial output.

So the pixels are checked by hand, with scripts/dev/screenshot.py, which boots under QEMU and captures the framebuffer to a PPM file. Nothing runs it automatically. A change that draws the desktop entirely off-screen, or in the wrong pixel format, or not at all, would pass CI.

That is a deliberate gap and not an oversight - a pixel-comparison gate is brittle in a way that costs more than it catches, this early - but it means "the GUI works" is a claim resting on somebody having looked recently, and it should be read that way.

Limits worth knowing before extending this

  • One mode, chosen by firmware. No mode setting, so no resolution change at runtime and no response to a monitor being swapped.
  • One buffer. Drawing is visible as it happens. Anything large enough to be slow will be seen being drawn.
  • No clipping discipline in depth. Windows are drawn in order; there is no general clip-rectangle stack, so a new kind of overlapping element needs its own thinking rather than inheriting one.
  • Pixel format is read from boot info, not assumed. Code that hardcodes BGRA will work on QEMU and fail on hardware that reports RGBA.

Process semantics

How a process is created, replaced, signalled and reaped here, and the places where matching Linux turned out to mean something other than what the code first assumed.

Two user windows

VibeOS's own programs link at 0x8000000000. Linux programs link at 0x400000, which is inside the kernel's identity map - so the physical range behind the low window is reserved out of the allocator, and nothing of the kernel's can live where a process would shadow it. Which addresses a process may use is decided in one place, hw_user_addr_ok.

This is not a stylistic split. An unmodified static Linux binary has its load address baked in, and refusing it would mean refusing the whole point.

Starting a program

execve builds a System V startup stack: argc, the argv pointers, the envp pointers, then the auxiliary vector. A C runtime reads more of that vector than its name suggests it must:

  • AT_PHDR, AT_PHNUM, AT_PHENT - glibc walks its own program headers.
  • AT_ENTRY, AT_BASE.
  • AT_RANDOM - sixteen bytes used to seed the stack canary. When it was absent, the pointer was null and the process dereferenced it during startup, before main. getrandom is deliberately still ENOSYS; that is a separate promise and this one had to be kept first.

The entry rsp must be 16-byte aligned, which is why _start cannot be C.

argv[0] is not the path. BusyBox is twenty commands in one binary and decides which it is by the name it was invoked under; FAT stores names upper case with an extension. The shell supplies the name, the path only opens the file.

/proc/self/exe is stored absolute even when the caller used a relative path. glibc does not merely prefer that - it asserts on it during startup and aborts.

fork

Copy-on-write. The page tables are shared with the writable bit cleared and PTE_COW (bit 9) set, and a frame refcount table says how many address spaces hold each frame.

Three places have to agree, and they do not look related:

  • The fault handler must accept kernel-mode writes. The kernel writes user buffers with CR0.WP set, so a read() into a COW page faults from ring 0.
  • hw_user_range_ok must treat a COW page as writable. It is writable; it just needs a copy first. Rejecting it makes a valid buffer look invalid.
  • A second fork must preserve the COW bit rather than treating the page as plain read-only, or the third process shares a page nobody will copy.

The refcount table is indexed from the region base. Indexing it from the end of the table instead freed pages that were still in use - a bug that presents as unrelated memory corruption much later.

Signals

Delivered on the return path to user space, never mid-syscall. The frame is built below the red zone, and return goes through the user library's own SA_RESTORER trampoline rather than anything the kernel supplies.

Linux sigset_t bits are numbered from zero: bit 0 is signal 1. This kernel numbers them by signal number. The conversion happens only at the boundary, in hw_sigset_from_user and hw_sigset_to_user. Doing it anywhere else means two conventions in the same code path and an off-by-one that only shows up for whichever signal happens to be tested last.

Exit and wait

A wait status is not an exit code. The exit code lives in the high byte and the signal number in the low seven bits. 128 + sig is what a shell prints, not what the kernel stores - copying the shell's arithmetic into the kernel produces a status that looks right in one test and is wrong everywhere else.

Descriptors are inherited across fork and released on exit, pipe ends included. Exit that did not release pipe ends left readers waiting on a writer that no longer existed, which is a hang rather than an error.

Pipes

pipe2, dup, dup2, inheritance across fork, and SIGPIPE for a write with no reader. The boot self-test runs ls /EFI/BOOT | wc -l in BusyBox's shell and the gate fails if the pipeline does not complete - a pipeline that deadlocks and a pipeline that returns the wrong number are both caught, because the count is asserted and not merely printed.

Scheduling and locks, where processes meet them

Spinlocks mask interrupts, so anything slow under one is slow with the timer off - and under emulation that is indistinguishable from a hang. execve is the long one: two megabytes loaded, hundreds of pages mapped, an address space torn down. It therefore takes g_exec_lock preemptibly, with interrupts left on, which is safe only because no interrupt handler ever takes that lock.

That choice has a consequence recorded in CLAUDE.md and not yet resolved: a lock whose holder can be preempted must not be waited on by a core that has interrupts masked, because such a core never reaches the scheduler and can never hand the holder a CPU to finish on.

What is verified

The boot gate asserts behaviour rather than log lines: an unmodified static Linux binary ran, BusyBox dispatched an applet and did file work, the interactive shell ran, signals were delivered, fork produced a child that was reaped with the status the parent expected.

Ctrl-C becoming a real SIGINT is implemented but not gated - it is checked by hand.

Security

Security model

VibeOS uses a layered security model combining isolation, rights-based access, system policy, and hardening.

Core principles

  • least privilege by default
  • small trusted computing base
  • explicit privilege transitions
  • defense in depth
  • auditable security decisions

Trust domains

  • kernel core
  • privileged system services
  • standard system services
  • compatibility runtimes
  • sandboxed applications
  • guest or VM environments

Access control

Baseline mechanism

  • handles with rights masks
  • capability-aware object access
  • service-mediated access to sensitive resources
  • per-process security tokens (uid, gid, capability_mask) with token inheritance on process spawn

Policy layer

The system should support a mandatory policy engine for:

  • filesystem access
  • device access
  • network permissions
  • process interaction rules
  • compatibility subsystem restrictions

This policy layer can evolve toward a MAC framework.

Current implementation status:

  • policy checks implemented for filesystem open, network bind, and process spawn.
  • policy required-capability bits are runtime-readable and kernel-mutable.
  • process spawn authorization evaluates the calling process token (or kernel token in kernel context), not a fixed global mask.
  • process entries expose security labels with inheritance on spawn and scoped mutation controls.
  • process interaction checks support label-based allow rules with capability-based override.
  • thread entries now keep a token snapshot inherited from their owner process, and token updates propagate to live threads.
  • policy runtime now includes an enforceable MAC interaction gate (mac_enforced) layered on top of label and capability checks.

Sandboxing

Application sandboxes may restrict:

  • filesystem view
  • network access
  • device classes
  • IPC peers
  • memory execution permissions

Compatibility runtimes should run inside restricted containers unless explicitly trusted.

Hardening

  • kernel ASLR where supported
  • W^X and executable mapping controls
  • stack canaries and compiler hardening
  • structured syscall validation
  • guarded kernel memory regions
  • control-flow protection where toolchains allow

Identity and authentication

Early phases should support local user and service identities with secure credential storage. Network and enterprise identity integration is explicitly deferred.

Secure boot and integrity

The design should permit:

  • measured boot
  • signed kernel and core service images
  • verified system partitions
  • runtime attestation experiments in later phases

Observability and audit

  • auditable permission denials
  • security event logging
  • crash and tamper diagnostics
  • trace correlation for compatibility subsystem behavior

Current implementation status:

  • kernel security-audit ring buffer tracks privileged security-relevant operations.
  • audited operations currently include process spawn authorization outcomes, process token updates, and policy capability mutations.
  • non-kernel callers can only inspect their own security-audit events through caller-scoped syscall views.

Compatibility Strategy

Objective

VibeOS must support applications from Linux, Windows, and macOS without compromising kernel integrity or forcing the native kernel ABI to mimic every foreign operating system.

Strategy overview

The compatibility approach is layered:

  • native VibeOS ABI for first-party software
  • subsystem runtimes for translated execution
  • containers and personalities for compatible environments
  • lightweight virtualization for workloads requiring stronger fidelity

Support tiers

Compatibility promises are intentionally tiered:

  • Tier 0: native VibeOS applications using the native ABI
  • Tier 1: Linux command-line and service workloads through translation
  • Tier 2: Linux containerized workloads with stronger environment fidelity
  • Tier 3: Windows console and service-style workloads through subsystem services
  • Tier 4: selected Windows GUI workloads once graphics and input stacks mature
  • Tier 5: selective Darwin-oriented and macOS-adjacent runtime experiments, not broad parity

Linux compatibility

Priority

Linux is the first compatibility target because:

  • it unlocks developer tooling quickly
  • POSIX ecosystems are large and well documented
  • container-oriented workflows align well with the system design

Proposed approach

  • Linux personality runtime in user space
  • syscall translation to native kernel objects and services
  • ELF loader support
  • POSIX-compatible libc strategy in the compatibility environment
  • container and namespace model for isolating Linux workloads

What exists today

Ahead of the user-space personality, the x86-64 port serves the Linux ABI directly from the kernel, which is what makes it possible to run real programs now rather than after the full stack is built:

  • static ET_EXEC ELF64 images are loaded by a portable, host-tested parser; ET_DYN and anything needing an interpreter are refused clearly rather than loaded and left to fault at a puzzling address
  • programs start on a real System V stack with argc, argv, envp and an auxiliary vector, so a C runtime can find the program headers it was loaded from
  • thread-local storage works through arch_prctl, per task and preserved across context switches
  • an unmodified static BusyBox runs: it dispatches on its own name, reads files through openat/fstat/read, lists directories through getdents64, and its shell parses scripts, searches PATH, forks and execs. All of that is gated - the boot fails on busybox_did_not_run, busybox_applet_dispatch_failed, busybox_file_operations_failed or interactive_shell_did_not_run
  • process semantics a real program assumes are present: fork is copy-on-write, signals are raised, masked, queued and delivered on the way back to user space, and wait4 reports a status with the exit code in the high byte and a killing signal in the low seven bits
  • pipelines work. pipe2, dup, dup2, descriptor inheritance across fork, descriptors released on exit and SIGPIPE when the reader is gone are all implemented; the gate runs ls /EFI/BOOT | wc -l in BusyBox's shell and fails on pipeline_did_not_complete if the pipeline stalls
  • the syscall surface is listed in syscalls.md

What is still missing is the part that begins where a single static program ends. ET_DYN and PT_INTERP are refused, so "runs Linux programs" means static ones. clone with CLONE_VM or CLONE_THREAD returns -ENOSYS and futexes are answered honestly rather than implemented, so a threaded program does not run. There is no entropy source, so getrandom refuses. There is one working directory - every path resolves against the volume root, so getcwd says / and openat accepts only AT_FDCWD - one identity, and no terminal device. Stubbing the full Linux table is explicitly not the plan - a program that receives a fabricated success dies later and less legibly than one that receives -ENOSYS.

Fallback

For software requiring tight kernel semantic fidelity, provide lightweight VM execution with shared filesystem and graphics bridges.

Windows compatibility

Proposed approach

  • Windows subsystem service implementing Win32 and NT-oriented user-mode behaviors incrementally
  • PE loader
  • registry-like service abstraction
  • object and handle semantic translation where possible
  • graphics and multimedia support deferred after core process and file APIs

Key challenge

Windows compatibility requires significant semantic mapping around process creation, handles, synchronization, and filesystem expectations. This should remain outside the kernel core.

macOS compatibility

Proposed approach

macOS compatibility is the most constrained and should be phased carefully:

  • Mach-like messaging semantics can be emulated in user space where needed
  • selected Unix and userland tooling may run via POSIX compatibility
  • native app compatibility may require a managed runtime, translation environment, or guest-backed execution depending on legal and technical constraints

Policy

Early phases should not promise broad unmodified macOS binary support. The architecture should remain open to targeted support without overcommitting the kernel.

macOS compatibility must be evaluated not only as a technical problem but also as an ecosystem and legal one. The project should treat guest-backed execution, POSIX compatibility, and developer-tool portability as the realistic early focus rather than broad claims of native macOS application support.

Shared enabling technologies

  • unified object and handle translation layer
  • filesystem path semantics adapters
  • signal or exception translation
  • environment and process image loaders
  • graphics and input broker services
  • sandboxed compatibility domains

Current runtime prototype status

  • compatibility runtime state machine is implemented for Linux, Windows, and macOS targets (enable, translate, stats).
  • initial syscall translation probes are available per target to exercise end-to-end ABI bridging paths in host tests.
  • translation statistics expose translated vs denied syscall counts for observability and safety gating.
  1. Native toolchain and minimal userland
  2. Linux userspace and CLI compatibility
  3. Linux container runtime
  4. Windows basic console and service-process compatibility
  5. Windows GUI-oriented expansion
  6. Selective Darwin and macOS-adjacent runtime experiments

Userland

Goals

  • minimal but practical base system
  • service-oriented architecture aligned with the kernel model
  • strong tooling for bring-up, diagnostics, and automation

Core userland components

Init and service manager

  • boots essential services
  • enforces dependency ordering
  • restarts supervised services
  • exposes health and status APIs

Early phases should keep init and the long-lived service manager separate so that bootstrap concerns do not hard-code later supervision policy into the first process.

System services

  • device manager
  • filesystem manager
  • network manager
  • security policy service
  • session manager
  • logging and tracing services
  • compatibility subsystem hosts

Command-line environment

  • native shell
  • standard utilities
  • process inspection tools
  • filesystem and networking tools
  • tracing and crash-debug tools

Library strategy

  • small native C runtime and system library
  • C++ support for selected user-space components
  • stable userspace API layered over the native syscall ABI

Desktop and GUI direction

GUI is not part of the earliest milestones, but the architecture should anticipate:

  • compositor service
  • display server or protocol layer
  • sandbox-friendly application model
  • remote and virtual display support

The detailed desktop architecture, interaction/visual specification, security boundaries and staged implementation plan are in ui-design/. The current framebuffer desktop is a bring-up surface, not the production window-system target; see graphics.md for its verified scope.

Developer experience priorities

  • cross-toolchain support
  • deterministic builds
  • emulator-friendly boot flows
  • serial and structured logs
  • conformance and regression test harnesses

Current implementation snapshot

  • service lifecycle now includes start and stop paths across init, devmgr, vfs, and net services.
  • service manager exposes a health aggregation helper that reports currently running critical services.
  • runtime supervision counters are exercised by host tests to keep orchestration behavior regression-safe.
  • compatibility runtime core now includes per-target enablement and early syscall translation probes for Linux, Windows, and macOS layers.
  • user API now includes wrappers for process security label get/set and interaction checks over native syscall ABI helpers.
  • service manager now exposes restart-budget controls and failure reporting hooks to support stricter supervision policy behavior.
  • service IPC runtime now validates service identifiers/message types and supports reply correlation/status fields for ACK/ERROR flows.

Toolchain and Build Strategy

Goals

  • deterministic and reproducible builds
  • a clear split between host tools and target artifacts
  • support for kernel-grade debugging and tracing
  • a sustainable language strategy for C and C++

Language

The project is C and assembly. There is no C++ anywhere in the tree, and the CMake project declares only C ASM.

That started as a plan for C++ in the higher-level subsystems and did not survive contact with the work. The kernel runs freestanding, with no runtime, no allocator during early boot and no unwinder; every C++ feature worth having there would have had to be disabled, and what remained would have been C with different syntax. Assembly is used only where nothing else can express the job: the AP trampoline, the syscall and interrupt entry stubs, and the program entry point in user/prog/crt0.S.

Rules:

  • minimize dynamic allocation during early boot
  • keep the freestanding kernel environment separate from hosted user-space components; the hosted TLS adapter lives in its own target precisely so it cannot be dragged into the kernel image by a future reference
  • put portable logic in portable files - the ELF parser, the TCP/IP stack and the syscall translation model are all host-tested because they were written without touching hardware

Compilers

GCC and Clang are both first-class and both gated in CI, in Debug and Release. Neither is the primary one.

This is not even-handedness for its own sake. The two disagree about enough to have caught real bugs that the other missed: an entry stack misaligned by eight bytes faults on the aligned SSE store Clang emits and not on the code GCC generates; a page of SSE spills at -O2 triple-faulted a core that GCC at -O0 started cleanly. Two compilers are a cheap second opinion about undefined behaviour.

Linking follows the same split: the GCC path builds an ELF and converts it to PE, the Clang path links PE natively with LLD. Both produce a bootable image and both are boot-gated.

Build system

CMake 3.21+ with Ninja. Targets are separated by what they are allowed to assume about their environment:

TargetKind
vibeos_kernel_corefreestanding kernel logic
vibeos_kernelthe linked kernel image
vibeos_bootloader_uefiUEFI bootloader
vibeos_user_coreportable code shared with user space
vibeos_tlshosted Mbed TLS adapter, deliberately outside vibeos_user_core
vibeos_user_hello / _sh / _net / _taskring-3 programs, linked with user/prog/user.ld
vibeos_kernel_tests, vibeos_bootloader_testshost suites
vibeos_image, vibeos_vm_imagesbootable media
fuzz_inet_inputlibFuzzer harness over the network receive path

The source list lives in cmake/core_sources.cmake and is read by both CMake and scripts/run-tests.ps1. It is a single file because it used to be two, and the copies drifted three times - each time breaking CI on Windows only, after the change had already been declared verified on Linux.

The build system should produce:

  • kernel ELF or PE image as required by the boot path
  • bootable disk image
  • symbol files for debugging
  • emulator launch targets

Cross-compilation model

  • host builds run on Linux, macOS, or Windows development machines
  • target triples are explicit and never inferred from the host
  • freestanding kernel and hosted user-space builds use separate compiler flags and runtime assumptions

Debug and observability tooling

The concrete list, with the reason for each, is in development_tools.md.

  • QEMU as the first emulator target
  • GDB and LLDB support from the start
  • serial logging as the first diagnostic channel
  • symbolized crash output for kernel panics in debug builds

Build variants

  • debug for bring-up and diagnostics
  • release for performance profiling
  • hardened for security validation once the base system matures

Early deliverables for phase 2

M1: Toolchain and Build Skeleton (completed)

The following are available:

Build System

  • CMake 3.21+ with Ninja support (primary generator)
  • GCC fallback for environments without CMake/Ninja
  • Reproducible host-to-target build for kernel core and user-space components
  • Modular target separation: vibeos_kernel_core, vibeos_user_core, vibeos_image

Commands

# One-command configuration and build
cmake -S . -B build -G Ninja -DVIBEOS_BUILD_TESTS=ON -DVIBEOS_BUILD_KERNEL_IMAGE=ON

# Build all targets (kernel, user-space, boot image, tests)
cmake --build build

# Run host-side tests
powershell ./scripts/run-tests.ps1 -BuildDir build

# Run tests with QEMU smoke test (if available)
powershell ./scripts/run-tests.ps1 -BuildDir build -RunQemu

# Standalone QEMU boot test
powershell ./scripts/run-qemu.ps1 -BuildDir build -ImagePath build/artifacts/vibeos_boot.img

Artifacts Generated

  • build/artifacts/vibeos_kernel.elf kernel ELF image (loadable by bootloader)
  • build/artifacts/efi_root/ bootable EFI media, including the ring-3 programs staged as EFI/BOOT/*.ELF
  • build/artifacts/vibeos_esp.img, vibeos.iso, vibeos.vdi, vibeos.vmdk importable images (see the README)
  • build/artifacts/boot_manifest.txt manifest of boot artifacts
  • build/vibeos_kernel_tests.exe host-side unit test executable
  • artifacts/test-summary.json structured test results for CI/agents
  • artifacts/qemu-serial.log serial output from QEMU (if run)

Verified Configuration

  • CMake generator availability detection
  • Automatic fallback to manual GCC compilation if CMake/Ninja unavailable
  • Toolchain version capture (cmake, gcc, qemu, ninja)
  • Build artifact validation before test execution

M2/M3: Boot and Early Kernel (completed)

  • one-command QEMU boot path (scripts/qemu-cli-smoke-linux.py), which boots four vCPUs under pure TCG and asserts state rather than grepping for a token
  • image packaging pipeline producing UEFI media and importable disk images

Dependencies

third_party/mbedtls is the only external dependency, pinned as a submodule and fetched recursively - since 4.x it carries nested submodules of its own. It is optional: without it the build simply has no TLS, unless VIBEOS_REQUIRE_TLS is set, which the Linux CI matrix does so that a missing submodule fails loudly there instead of silently degrading.

Debugging a hang

Serial output is the first channel, but a hung guest has stopped producing it. The QEMU monitor is the second: info registers -a gives every core's RIP, and resolving those against nm on the kernel ELF turns "it froze" into a function name. Two intermittent hangs were diagnosed this way that no amount of added logging would have found, because the hang was in code holding the lock the logger needed.

Development Tools

What a development machine needs, and what each thing is actually for. Nothing here is required to build VibeOS beyond the first section - the rest exists to answer questions that are otherwise answered by guessing.

Install

On Ubuntu or WSL:

# build and boot
sudo apt install -y cmake ninja-build build-essential clang python3 \
                    qemu-system-x86 qemu-utils ovmf xorriso

# diagnosis and compatibility work
sudo apt install -y strace gdb musl-tools busybox-static gcovr lcov

Build and boot

ToolWhy
cmake (3.21+), ninjaThe build. Ninja is the default generator; the Windows script falls back to a manual GCC compile when neither is present.
gcc and clangBoth are gated in CI, in Debug and Release. They disagree about enough - alignment assumptions, optimisation of inline assembly, PE linking - that each has caught bugs the other missed. Building with only one is building half the matrix.
qemu-system-x86, ovmfThe boot gates. OVMF is the UEFI firmware; without it there is no boot path to test.
qemu-utilsqemu-img, which produces the .vdi and .vmdk images.
xorrisoThe bootable .iso. Optional: its absence only removes that one artifact.
python3The boot smoke harness, the FAT16 image writer, and the ELF-to-C blob converter.

Run everything through scripts/; see the README for the commands.

Diagnosis

ToolWhy
straceThe ground truth for Linux compatibility. Run a real static binary on real Linux and read the exact syscalls it makes, in order, instead of predicting them. This is how the startup set was confirmed rather than guessed - see below.
gdbRemote kernel debugging against qemu -s -S. Reaches what serial output cannot: the state of a guest that has stopped talking.
nm, readelf, objdump (binutils)Already present with the compiler. nm on the kernel ELF turns a raw RIP from the QEMU monitor into a function name, which is how two intermittent hangs were diagnosed - the hang was inside code holding the lock the logger needed, so no amount of extra logging would have found it.
QEMU monitorNot a package. info registers -a dumps every core's state on a frozen guest. When the guest has gone quiet, this is the only channel left.
gcovr, lcovCoverage over the host suites: which branches of the ELF parser and the TCP/IP stack the tests never reach. A test suite that passes says nothing about the code it never executed.

Compatibility targets

ToolWhy
musl-tools (musl-gcc)Builds a genuinely unmodified static Linux binary, small enough to load. A static musl hello is about 47 KB; the same program against static glibc is closer to a megabyte and calls a far larger startup set, so musl is both the easier target and the more honest first one.
busybox-staticA real, non-trivial static binary - shell, ls, cat - as the target after the musl hello. It is the step from "a libc starts" to "a program works".

What a static musl binary actually asks for

Recorded with strace on Linux, in order, for a printf and malloc program:

execve  arch_prctl  set_tid_address  brk  brk  mmap  mmap
ioctl  writev  munmap  writev  exit_group

Every one of those is implemented. That list is the reason the startup syscall work targeted what it did, and it is worth re-recording whenever a new target binary is chosen: the set is a property of the libc, not of the kernel, and guessing at it produces stubs nobody needs alongside gaps that stop everything.

The remaining blocker is not a syscall. A Linux executable is linked at 0x400000, which falls in the first 512 GiB of the address space - the region VibeOS currently maps as the supervisor-only kernel identity map, with user memory living in the next PML4 slot instead. Loading a real binary means being able to map user pages at the address it was linked for.

Testing Strategy

Goals

  • catch regressions before they reach low-level integration work
  • validate contracts between kernel and services
  • measure compatibility progress with repeatable suites
  • keep bring-up fast enough for daily development

See also scripts/dev/README.md, which lists what each development script does and why it exists. (The April test-automation specification and its feedback profiles were never built; they are in archive/.)

What runs on every push

  • Build matrix: gcc and clang, Debug and Release. Two compilers disagree about enough - alignment assumptions, optimisation of inline assembly, PE linking - that one of them alone has repeatedly been insufficient.
  • Host suites (ctest), plus the same suites again under AddressSanitizer and UndefinedBehaviorSanitizer. The protocol code parses attacker-chosen input, and the sanitizer run is the only gate that sees memory errors at all.
  • UEFI boot gate under OVMF, and a boot-to-CLI gate that boots four vCPUs under pure TCG, asserts state invariants (DHCP lease non-zero, all cores online, no unexpected fault, no panic) and completes a TCP round trip to a host echo server.
  • CodeQL static analysis.
  • libFuzzer over the network receive path.
  • Nightly: repeated boots and a longer fuzz run. Dependabot tracks the actions and the submodules.

Two lessons are built into the above, both learned the expensive way:

  • The boot gate must assert state, not the presence of a token. A kernel can print BOOT_OK and still have failed to get a DHCP lease or bring up three of its four cores.
  • Local verification must use the same configuration as CI. Roughly twenty clean local runs once validated a hardware-accelerated setup while CI ran pure emulation; forcing -accel tcg locally reproduced the CI hang in two runs out of three.

Test layers

Unit tests

For pure logic that can run on the host:

  • allocators where host execution is possible
  • parser code
  • protocol encoders and decoders
  • compatibility translation helpers

Kernel subsystem tests

For logic requiring the target environment:

  • paging and address-space operations
  • scheduler invariants
  • IPC semantics
  • timer behavior
  • handle-rights enforcement

Service integration tests

  • init and service manager lifecycle
  • driver host restart behavior
  • filesystem service contracts
  • network stack control-plane flows

System boot tests

  • boot image loads in emulator
  • kernel reaches known checkpoints
  • basic user-space services start in order

Compatibility tests

  • Linux syscall and libc behavior subsets
  • Windows process, file, and console behavior subsets
  • selected Darwin and POSIX semantic checks where applicable

Execution environments

  • host-native tests for portable libraries
  • QEMU for deterministic bring-up and CI
  • optional hardware validation later for timing and device realism

Release gates

No milestone should be considered complete without automated validation for its core claims. In early phases this means emulator-based smoke tests at minimum; later phases require subsystem-specific regression suites.

Required early artifacts

  • test harness runner
  • boot smoke test
  • serial log assertions
  • golden compatibility samples for Linux-first milestones

Debugging expectations

  • failures should capture build variant, git revision, emulator configuration, and log excerpts
  • kernel failures should preserve a structured panic signature where practical
  • compatibility failures should distinguish loader, translation, and runtime-service faults

Implementation Progress (Macro Areas)

Last review: 2026-09-29

One row per area, the state in a sentence or two. How each got there is in the linked files, which keep the full history - including the long status cells this table used to carry, moved there verbatim on 2026-09-29.

Macro AreaStatusDetails
Review FindingsTracked through M-083. Open: M-068, M-070 (instrumented, unexplained; not seen since the stack grows on demand), M-080 (a descriptor's calls walk a path: mitigated with ESTALE, not closed) and M-081 (the page cache's file identities are never given back: 512 of them now, and it says when they run out) and two internal families, the four-worker thread crash and rmap_mismatch.review_findings_tracker.md
BootloaderCompleted (boot-to-CLI verified).bootloader.md
Kernel CoreCore plan C0-C7 done (2026-09-27), C5's residue closed (M-071..M-073). Blast radius 1 for every device kind, syscall 2 by decision. See core/phases.md.kernel_core.md core_plan_revision.md core_perf_baseline.md core_c1_checks.md external_reviews_verified.md core_dead_code.md core_c1_blast_radius.md core_c2_observability.md review_sixth_verified.md mm_tlb_quarantine.md review_seventh_verified.md mm_brk_mmap_leaks.md uaccess_recovery_open.md core_c5_process_state.md pipe_eof_race_open.md core_c4_abi.md
Boot Repeatability24 of 24 clean on the reference configuration, from a long-standing 1 in 8; every boot gate check is sabotage-proved.boot_repeatability.md
Tasks and SchedulingS-P0 to S-P6 done: a transition table that refuses illegal changes, tenancy on every reference, classes and nice-weighted fairness, an enforced quantum, a fork and clone guard. Threads share their process's state and descriptors (M-072). See docs/sched/.sched.md process_scheduler.md sched_quantum_threads.md sched_torture.md
Memory ManagerP0-P6 done (ADR-0007): frame layer, reverse map, copy-on-write with TLB shootdown, watermarks and pinning, compaction, swap to a file with a second-chance clock. The reclaim path runs every boot (svc-reclaim, including a teardown racing it). See docs/mm/.memory_manager.md cow_stale_page.md mm_rmap.md mm_reclaim.md mm_compaction.md mm_swap.md mm_acceptance.md mm_demand_paging.md mm_frame_init_flags.md mm_soak.md mm_latency.md mm_swap_file.md lock_deadlock_bound.md console_lock_bound.md detector_checks.md A failure the gate was naming wrongly, found while chasing the argv one: three boots in ten failed as stress_run_did_not_finish and the stress run had finished - 120 rounds, exit 0, machine healthy to the CLI. What went missing was its marker, and all three logs held the same bytes: sixteen NULs then =120, where STRESS_OK rounds is exactly sixteen characters. A user stack buffer whose first sixteen bytes the kernel read as zero. interleaved_lines is not at fault - its docstring already states the limit that let this through - so a separate check names the mechanism now (mm_ring3_write_nul.md). Narrowed hard: the copy-on-write copy is faithful (a fold of the page before and after every resolution, cow_copy_changed=0), the page faults several times in the same process rather than once as a comment in the tree claimed, and the defect needs more than one core - eight boots at -smp 1 produce zero corrupted writes against three to five in ten at four. The process migrates, measured on both sides. The explanation that fits every symptom - a stale writable TLB entry surviving a fork that sent no shootdown because the process is single-threaded - is refuted by one grep: there is no PTE_GLOBAL and no CR4.PGE in this tree, so every CR3 write flushes. The mechanism is open and the candidate list is written down (mm_lost_store_smp.md). Four explanations for the argv defect were instrumented and closed on the way - the desktop's buffer cannot reach the low user window (pmm_reserve truncates the region), the framebuffer is outside managed RAM, a canary shows no overrun, and no frame of it is shared or lost - and the question that file named as its next step was already answered by double_allocs, a counter nobody had connected to it. The argv defect did not reproduce in eighteen boots against a recorded rate of one in six. ROOT CAUSE FOUND AND FIXED: the kernel set CR4.OSFXSR, uses 6955 XMM instructions of its own, and contains zero fxsave/fxrstor/xsave - so a user task's SSE registers survive a syscall, an interrupt or a context switch only by luck. clang stores 16-byte stack buffers with movdqa, which is why the corruption was always exactly sixteen bytes - one XMM register - a width nothing in the memory manager could explain. The zeros are not a fresh page: svc-stress forks a hundred children running a binary that opens with pxor %xmm0,%xmm0. And it explains the argv vector too: COL_TITLETXT is 0x00FFFFFF, the desktop paints from the kernel on interrupts, and a vectorised fill leaves two white pixels in a lane pair that the resumed task stores into its own buffer - the desktop never touches that memory, which is why every memory counter read zero and was right. Ten hypotheses were measured and killed first, all of them in the memory manager because the symptom looked like memory (mm_no_fpu_context.md). Fixed, in two halves. The context switch saves and restores a 512-byte per-task area (eager, not lazy: CR0.TS needs a fault handler, per-core ownership and cross-core invalidation, three mechanisms on the path this kernel has the most defects in); the initial state is built by hand because MXCSR at offset 24 reads zero as unmask every SSE exception; fork copies it, threads get a clean one, and CR4.OSFXSR is set on the boot processor too - only ap_boot.S ever set it, so the BSP had it because UEFI leaves it on. That half alone took the corruption from three boots in ten to one in twelve, which is the number that would have been called a fix on a five-boot sweep. The second half is -mno-sse on the kernel, as Linux does: a syscall returning to the same process never reaches the scheduler, so nothing saves anything. 22 boots: 0 corrupted writes, 0 xmm lost, host tests and boot gate green. One near-miss recorded: the first attempt gated the flags on if(IMAGE AND NOT TESTS), and both are on in every real build - it would have compiled to nothing while looking like a fix. Open: the argv vector that is not a pointer reads 0x00ffffff00ffffff - two white pixels, COL_TITLETXT from the GUI - and the same value turned up independently as an out-of-range sector in the block layer. The desktop is painting over memory somebody else owns, which is a recurrence of a defect the code comments already describe, and it explains why every memory counter is zero: nothing is freed early, frames are handed out twice (mm-argv-poison.txt). Two detector defects were fixed on the way: the mapper count was a global two cores overwrote, and the free-side check samples one free in sixteen, so its zero was being read as evidence.
Program LoadingX-P0, X-P1, X-P3, X-P4 done; X-P2 partly: image pages come from the page cache and the staging buffer is 128 KiB, demand paging of images is open. See docs/exec/.exec.md exec_silent_failure.md
Splitting arch_hw.cDone (2026-09-29): 12,174 lines before C4, 1,792 now - the CPU, the locks, the IDT, the trap handler and early init. The rest is in mm_bridge.c, task_life.c, task_switch.c, smp.c, userland.c, diagnostics.c, net_bringup.c and io_bringup.c.arch_extraction.md
Interrupt HandlingIn progress: CPU traps, the LAPIC timer on every core, IO-APIC routing, disk and network interrupts verified.interrupt_handling.md
System Call InterfaceIn progress: 74 Linux syscalls through one table-driven dispatcher, pointer arguments declared per row (C4). The plan for all 373 is docs/abi/; A0 is done (25 workloads, 1,530 LTP tests as an oracle - abi/corpus.md) and A1: one registry of every number, which the kernel answers from when there is no row (refused is counted, missing and deferred fail the gate by number). A2: the handlers reach the kernel only through include/vibeos/ksvc.h - a services interface meant for every personality, Windows included - and run in the host tests on a fake of it, with every partial row's gap written as a failing expectation. A3: descriptors name counted open file descriptions - dup and fork share an offset, 1024 per process, close-on-exec, dup3/fcntl/close_range - and each file type (file, directory, pipe, socket, console) has its own operations in kernel/abi/files/, shared by every personality. A4: paths - a working directory and root per process, one walk through the mount table (kernel/fs/path.c), dirfd honoured, cd in the shell gated, and the interpreter substitution deleted: the loader is found at /lib/ld-musl-x86_64.so.1, which needed FAT long names. A5: every Linux structure and constant the handlers use is declared once (include/vibeos/linux_layout.h) and compared - 232 comparisons - with the host's own uapi headers, and a check refuses a declaration the test does not compare. L1 (files and paths) is under way in eight steps, the filesystem first: the VFS has the operations a POSIX filesystem needs and one walk that follows symbolic links (step 1), and tmpfs - modes, owners, links, sparse files, rename-over - is mounted at /tmp with a nightly torture against a model (step 2); regular files write at their offset with open's flags as Linux means them, and twenty file calls - pread/pwrite and the vector forms, truncate, fsync, fallocate, sendfile, copy_file_range, umask - are served (step 3); FAT writes in place - offsets, truncate, long names created, rename, rmdir, growing directories - checked against mtools and a consistency checker, on the host and on the volume each boot leaves behind, and the whole-file write path is gone (step 4); names and metadata - stat, statx, access, rename, rmdir, link, symlink, the chmod, chown and utime families, statfs, mknod, openat2 - thirty-two rows, 108 of 373 done (step 5); record and whole-file locks with deadlock detection, directories listed with whole names, real types and inode numbers through getdents64 and getdents, the xattr calls answering as a filesystem without them does - 124 done (step 6); the console is a terminal with canonical and raw modes, so BusyBox's shell runs interactively at boot, with a minimal poll ahead of L4 (step 7); the corpus's fifteen file workloads - BusyBox's applets, SQLite, Lua - run at boot and are compared line for line with what Linux printed (step 8). L1 is closed; its LTP tests wait for L3's file-backed mappings. L3 (memory) is under way in six steps: mmap honours MAP_FIXED, hints and private file mappings (step 1); shared mappings stay shared across fork and a shared mapping of a tmpfs file is the file's own pages, with msync (step 2); LTP's 382 tests for L1 run (step 3: 131 passed, 10 failed, 177 broken, 57 not applicable, 7 did not run), which needed /proc, /etc/passwd, nanosleep and SA_RESTART, and found eight defects in L1's calls, fixed; madvise, mincore, the mlock family, mremap and memfd_create (step 4); the user stack grows on demand, two megabytes of it, with four pages mapped at exec (step 5, taken early); SQLite and Lua run built against glibc and dynamically linked, and SQLite runs in WAL mode with memory-mapped I/O (step 6). L3 is closed; its gaps are named in the registry. L7 (IPC) is closed (2026-10-12) in six steps: System V message queues, semaphores and shared memory with Linux's numbering and permissions, a segment an unnamed tmpfs file and its attach count read off the processes' regions; POSIX message queues; splice, tee and vmsplice over a pipe that can be looked into; /proc's first writable files, the IPC tunables (steps 1 to 5); a producer and consumer over SysV IPC under musl and glibc compared with Linux, whose LTP run found vmsplice waiting for room Linux does not wait for (step 6). LTP 64 of 81 from 0; what is left is netlink, a 4 KiB pipe, a tick-long sleep, or not x86-64's. L6 (threads and scheduling) is closed (2026-10-12) in four steps: the rest of futex, the futex2 calls and robust lists (step 1); the sched_* calls over a new real-time class, affinity, getcpu, ioprio and membarrier (step 2); clone making a process on its parent's memory - posix_spawn and vfork - with CLONE_PARENT, the tid words, and a task table of 64 (step 3); a thread pool under musl and glibc compared with Linux, which found that a fatal signal ended one thread instead of the process, that the TLB quarantine leaked when full, and that the mm lock's waiters did not answer a flush (step 4). LTP 53 of 67 from 16. L5 (sockets) is closed (2026-10-06) in four steps: the rest of the BSD calls on a neutral socket interface every personality can fill (step 1); AF_UNIX stream sockets, named in the filesystem or the abstract namespace (step 2); datagrams and SCM_RIGHTS on counted descriptions (step 3); BusyBox wget against the host, a DNS lookup through musl, a Unix-socket server and client at boot - and, from the phase's LTP run, a loopback the stack never had, and a half-closed connection that lost its slot under an open descriptor (step 4). LTP 44 of 64. L4 (event loops) is closed (2026-10-06) in seven steps, planned from what BusyBox's httpd, nc and tail -f ask for: poll, ppoll, select and pselect6 on one engine, with sockets saying what they can do (step 1); eventfd, timerfd and signalfd as file types that poll sees (steps 2 to 4); epoll - level, edge, one-shot, nested - on the same engine (step 5); inotify from one place in the file layer every filesystem passes (step 6); the programs themselves at boot - tail -f, httpd served to the host, nc both ways - which needed setsockopt and shutdown from L5 and found an endless loop in the TCP stack's flush (step 7). LTP for the phase: 58 of 83, the rest mostly socketpair and AF_UNIX (L5), setting the clock (refused) and resources the run does not stage. L2 (processes, credentials, time) is closed in seven steps: the programs - BusyBox's traps, timeout, ps and time compared with Linux, a stop that holds, orphaned process groups hung up (step 7); /proc about processes, /dev and getrandom (step 6); waiting by group and waitid, pidfds, clone3 and execveat (step 5); resource limits, five of them enforced, priorities and personality - and the scheduler's policy running on the machine for the first time (step 4); timers - alarm, interval timers and POSIX timers, fired by the tick (step 3); signals are delivered as Linux delivers them, with a siginfo and a ucontext, faults go to a handler that asked, and SIGCHLD is raised (step 2); a process has credentials, the sixteen calls that read and change them, and L1's file operations ask who is asking (step 1; LTP's L1 list after it: 154 passed (131 before it), 16 failed, 144 broken, 65 not applicable, 2 did not run. What is left is mostly a loop device, alarm, and files under /proc and /dev.).system_call_interface.md
IPC SubsystemIn progress: pipes and futexes on the machine; the native IPC is a host model.ipc_subsystem.md
Driver FrameworkIn progress: virtio-blk, AHCI, virtio-net, PS/2 keyboard and mouse, framebuffer - registered devices (C7), both disk controllers gated.driver_framework.md
Filesystem LayerI0-I6 done, I7 nearly: FAT read-write on the boot volume; ext2, ISO9660, NTFS and exFAT registered and gated from images the host's own tools built; partition tables, a mount table, a journal, a log disk. See docs/io/.filesystem_layer.md io_blkdev.md io_read_contract.md io_one_cache.md io_writes_proved.md io_volumes.md io_mounts.md io_parttab.md io_gpt.md io_ext2.md io_iso9660.md io_ntfs_exfat.md io_logsink.md io_boot_disk.md
Networking StackIn progress: IPv4, ARP, ICMP, UDP, TCP (RFC 5961 resets), DHCP and DNS; a DHCP lease and a TCP round trip gated every boot.networking_stack.md
User Space InterfaceIn progress: unmodified Linux binaries from ring 3.user_space_interface.md
Diagnostics & ObservabilityIn progress: runtime detectors, a must-be-zero registry and a persistent log disk, all gated; the checks are themselves checked (anchors, assertions covered, counters produced).diagnostics.md
Init SystemIn progress: native init is PID 1 and supervises a service manifest - start, stop, bounded restart, and a crash the kernel survives.init_system.md
Linux CompatibilityIn progress: static, PIE and dynamically linked musl binaries run and are gated - BusyBox, a shell, threads, signals, sockets.linux_compatibility.md
Windows CompatibilityW2 done (2026-10-17): Wine's own conformance tests run on VibeOS, on our ntdll (system calls, a DLL loader with named stubs for what is missing, the Rtl runtime), msvcrt and kernel32; the kernel has the object manager (handles are descriptors with access masks), events, mutants, semaphores, the waits, Sleep and image sections. ntdll:om, ntdll:sync and kernel32:sync run and report failures until they reach threads and timers, which are W5's. W3 done (2026-10-21): memory reserved and committed, made on first touch; NtAllocate/Free/Protect/QueryVirtualMemory and kernel32's VirtualAlloc family; a process heap that grows, with msvcrt's malloc on it; an image's .bss left to the page fault; data sections and their views; and the reviews' M-106, M-100 and M-108 fixed. kernel32:virtual runs without a failure until CreateProcessA (W6); ntdll:virtual, since NtAllocateVirtualMemoryEx and its placeholders (2026-10-22), until test_NtMapViewOfSection needs files (W4), failing only checks that need a low arena or write watches. W4 done (2026-10-26): files - NtCreateFile and the file services around it, names without case and beyond ASCII, share modes and mandatory byte-range locks, and kernel32's file API over them; ntdll:directory runs to its end, ntdll:file stops at APCs (W5). Plan: abi/windows.md.windows_compatibility.md
macOS CompatibilityNot started (same, and deliberately dormant).macos_compatibility.md

Notes

  • Status reflects code currently present in repository, not only planned milestones.
  • A file existing under user/compat/ is not progress. The Windows and macOS translators are twenty-four lines each, know two syscall numbers, and nothing calls them; they are tracked as not started for that reason. The Linux compatibility that works is the kernel syscall layer, which is a different piece of code entirely.
  • "Verified" here means a gate fails if it regresses, unless the entry says otherwise. The framebuffer desktop is the standing exception: its serial-visible state is gated, its pixels are checked by hand with scripts/dev/screenshot.py.
  • Each macro-area file contains:
    • implemented items verified in code
    • pending items
    • next execution checkpoint

Subsystem Rewrites: Order and Scope

What is planned, what is deliberately sketched rather than planned, and why.

The memory manager is being rebuilt to a written plan with layers, invariants, host tests and sabotage cases. That treatment came from one decision - stopping to plan before writing code - and it is the only part of this kernel that has it. Everything else lives in kernel/arch/x86_64/arch_hw.c: 8312 lines, 267 static functions, holding the scheduler, syscalls, program loading, pipes, signals, the network glue, the interrupt controllers and the console, with no boundary between any of them.

Order

#SubsystemPlanWhy here
1Memory managerdocs/mm/In progress: P0-P3 done, P4 at steps 1-2
2Tasks and schedulingdocs/sched/Full plan. Where the most expensive defects have been, and P4 step 3 needs its ownership layer
3Program loadingdocs/exec/Full plan. Blocks memory P4 step 3: execve cannot fault pages in while it copies through a staging buffer
4Syscalls and the Linux ABIdocs/abi/Planned 2026-09-29: every one of the 373 numbers assigned, and the refactor underneath first
5Network and pipessketched belowLeast entangled with memory and lifetime; least urgent

After the Linux ABI's L7 (decided 2026-10-12)

L1 to L7 of docs/abi/ are closed. Before L8 (system administration), in this order:

  1. The Windows system calls - the second personality, over the same include/vibeos/ksvc.h every Linux handler reaches the kernel through. Planned in abi/windows.md: our own ntdll, an object manager in the kernel, Wine's conformance tests as the oracle.
  2. The filesystem's hierarchy - what lives where, from the boot volume to /tmp, /proc and /dev, decided rather than accumulated.
  3. The user interface - designed, not grown from the boot console.
  4. Security, all of it - isolation, privilege, sandboxing; L9's capabilities, seccomp and Landlock are its Linux-facing part.
  5. The applications that manage the system.
  6. The rest of networking.
  7. System administration (L8), last, once what it administers is defined.

Implementation sequencing is refined when a subsystem is next, for the reason the following section gives. By maintainer request on 2026-10-17, the filesystem namespace and desktop/UI architecture have been designed ahead of their implementation slot: see filesystem-design/ and ui-design/. This is planning-only work; it does not reorder the active runtime milestones or authorize a format/ABI freeze.

Why 4 and 5 are sketched and not planned

A plan written for work that starts in a month is a prediction, not a design. The memory plan has already been corrected twice at contact with reality - the ownership bit it specified was already taken by PTE_COW, and its completion criterion measured a pre-existing flakiness rather than the phase. Both were found by executing, not by planning harder.

So the two furthest subsystems get their objective, their invariants and their boundaries recorded now, while the knowledge is fresh, and the phase-level detail when they are next.

4. Syscalls and the Linux ABI

Planned in full on 2026-09-29 - docs/abi/. The objective and invariants below are carried into it unchanged; they are kept here as the sketch it started from.

Objective. One dispatch table, one place that validates arguments, and a translation layer that says what it does not implement.

Invariants worth writing down now

  1. Every syscall argument that names user memory is validated in one place, and the reason for a refusal is reported (hw_user_range_why exists for this).
  2. An int argument arrives zero-extended; comparisons happen through VIBEOS_ARG_INT and never against all 64 bits. This has bitten and is recorded in CLAUDE.md.
  3. A sigset_t is converted at the boundary and nowhere else.
  4. An unimplemented syscall is refused and counted, never silently succeeded.
  5. A wait status carries the signal in the low seven bits and the exit code in the high byte - and every producer and consumer agrees.

Boundaries. Dispatch and validation are portable; the register ABI and the syscall entry are not.

5. Network and pipes

Objective. The receive path stops being reachable from two contexts without a stated rule.

Invariants worth writing down now

  1. The stack is entered from syscalls and from the timer interrupt; exactly one lock covers both, and nothing slow happens under it.
  2. A pipe's end count is a refcount, not a flag: a descriptor can be duplicated and inherited, and end-of-file depends on getting that right.
  3. Buffers handed to the device are owned by exactly one side at a time.

The rule that applies to all of them

Check a criterion against the baseline before using it to judge a change. Written here because it cost two days on the memory manager: a phase was declared unfinished for a day and a half against a boot count that no revision of this project has ever met.

Closed: the second syscall layer (deferred 2026-09-02, deleted in C3)

Settled by the core plan's C3: kernel/core/syscall.c was deleted and its vocabulary with it (2026-09-20); what remains of the question - kernel/proc/process.c - is recorded in scripts/dev/cases/core-dispatcher.txt. The text below is the deferral as it was written.

kernel/core/syscall.c and kernel/proc/process.c are a complete syscall dispatcher and process table that nothing in ring 3 reaches. The real path is vibeos_x86_64_syscall_entry, wired through MSR_LSTAR into the hw_sys_* functions in arch_hw.c; the portable dispatcher is called only from user/lib/user_api.c, which nothing else in user/ uses, and from the host tests.

An external review found three real defects in it, all latent for that reason:

  • vibeos_thread_create takes a raw pid with no caller/target relationship check, unlike PROCESS_TERMINATE beside it - a thread created that way inherits the target process's security token.
  • vibeos_proc_terminate marks a slot TERMINATED and never clears in_use, so after 32 process creations every further one fails permanently.
  • The caller id used for every privilege check comes from frame->arg2, supplied by the caller: passing 0 impersonates the kernel.

Deferred until the memory-manager plan is finished, deliberately. The question is not "are these bugs" - they are - but whether that layer should exist at all, and that depends on what the memory manager ends up exposing. Deciding now means deciding with less information than we will have in two phases.

The risk of deferring is bounded and worth stating plainly: none of it is reachable from ring 3 today, so this is a decision about architecture rather than an open hole. The risk of not writing the deferral down is the real one - a month from now this is simply code that is there, and nobody remembers it was a choice.

When it is taken, the options are: remove it, wire it and fix it first, or keep it as the host-tested model it currently is with a comment saying so. What must not happen is that it becomes reachable before the three defects above are fixed.

Splitting arch_hw.c

Status: two cuts done. 9088 → 8359 lines. Last review: 2026-09-02

Why

kernel/arch/x86_64/arch_hw.c reached 9088 lines. Worse than the number is how it got there: during a session spent explicitly on "reducing the monolith" it grew by 633 lines, and nobody noticed until the person paying for the work asked why a file being reduced had gone from seven thousand to nine.

The growth was not careless. Every addition was a diagnostic or a fix that earned its place — hw_ctx_check, hw_cache_audit, hw_exec_refuse, the panic that parks every core. The problem is that the plans which were supposed to shrink the file only ever added portable code beside it. The task state machine became portable; g_tasks[], hw_task_alloc and hw_pick_next stayed exactly where they were.

So this plan has one criterion the others lacked: the line count of arch_hw.c is a completion condition, not a side effect.

Why it was hard to start

Everything in that file is static over shared file-scope globals. Nothing can move until somebody decides what the rest of the file is allowed to see. That decision is now kernel/arch/x86_64/arch_hw_internal.h, and it is deliberately not a public API: nothing outside kernel/arch/x86_64/ includes it, and a declaration arriving there should be read as a cost — a thing two files must now agree about — rather than as progress.

Cut 1 — the Linux socket syscalls (done, 2026-09-02)

kernel/arch/x86_64/linux_socket.c. None of what it does is architecture: reading a sockaddr out of user memory, allocating a descriptor, blocking until a connection arrives is Linux ABI translation over the portable TCP/IP stack in kernel/net/inet.c. It sat beside the GDT because that is where the file started.

The numbers, including the part that is not flattering:

lines
arch_hw.c before9088
arch_hw.c after8544 (−544)
linux_socket.c416
arch_hw_internal.h258
the three together9218 (+130)

The monolith lost 544 lines and the tree gained 130. That 130 is the seam, and it is the right thing to watch: a cut that costs more header than it removes source is a rename.

The seam is ten functions and six globals wide — and it is wider than the socket code alone needed, which is worth saying out loud. The socket file wants hw_task_t; a task holds a saved register frame, an address space and a descriptor table, so moving the task moved vibeos_x86_64_isr_frame_t, hw_proc_t, vibeos_hw_aspace_t, the Linux errno values and five constants with it. That is the honest cost of the first cut, and most of it is paid once: the next files to leave will find those types already on the far side.

One deliberate narrowing: g_current_task is a macro over per-CPU state. Exporting it would have put hw_cpu_t on the header for the sake of one integer, so the seam has hw_current_task() instead. A file that has been lifted out has no business knowing how this kernel finds the current core.

Cut 2 — signal delivery (done, 2026-09-02)

kernel/arch/x86_64/linux_signal.c. Building a frame on a user stack, calling a handler on it, and taking the frame back when the handler returns is Linux ABI, not x86. What is architectural about it - the selectors, the trap frame layout - is named in the seam header and nothing else in the file needs to know.

The two halves were four hundred lines apart, with the crash dumper and half the syscall table between them. That is the more useful thing this cut fixed: hw_signal_deliver and hw_sys_rt_sigreturn are one subject and now sit together.

lines
arch_hw.c after cut 18544
arch_hw.c after cut 28359 (−185)
linux_signal.c154
seam header258 → 330 (+72)

A correction to this plan's own estimate. The table below called this section 915 lines. It is not: the banner "delivering a signal" covers signals, the memory-diagnostic walk, the panic summary, the crash dumper and the whole Linux syscall table. The signal code proper is 185 lines. Section banners in that file describe where somebody stopped writing, not what follows.

The diagnostics group was measured and deliberately left. hw_walk_step, hw_frame_still_mapped, hw_panic_cpu_summary, hw_dump_vanished and vibeos_x86_64_crash_dump are ~427 cohesive lines and would make a good file - but they touch eleven globals including g_cpus, which would put hw_cpu_t on the seam. That is exactly the widening avoided in cut 1 with hw_current_task(). It needs an accessor pass first, so it waits rather than being taken at the wrong price.

Cut 3 — the storage and I/O bring-ups (done, 2026-09-07)

11166 -> 9995 lines. And the first number is the point of this section.

Between cut 2 and cut 3 the file went from 8359 to 11166 lines. Not because anybody decided to grow it: the I5 and I5b work - filesystem images, the log sink, the scratch device, the volume scan, the mount report - was written straight into it, one bring-up at a time, over three days in which this document sat here saying the length of the file is the completion criterion.

That is the same failure this plan already records once ("a session dedicated to reducing the monolith grew it by 633 lines"), at four times the size, and by the person who had read the note. Writing it down again rather than quietly fixing the number, because the lesson is evidently not learned by being written once: new code goes to its own file on the day it is written, not after.

What moved: hw_swap_bringup, hw_write_proof, the log sink bring-up and its console tail, hw_fsimages_bringup and the filesystem-image table, hw_scratch_bringup, hw_volumes_bringup, hw_mount_report. All of it runs once from the boot path and none of it is reachable from a syscall, which is why it separates cleanly.

The seam measured eight names in and seven out - narrow for a monolith, and narrow because the code was new. It had not yet grown the incidental couplings that make the older sections hard to separate. That is an argument for cutting recent additions first: the seam is cheapest on the day the code lands and gets more expensive every week it sits there.

Six statics had to stop being static (g_rootfs, g_swap_bitmap, hw_alloc_page, hw_free_page_why, hw_frame_identity_map, and the swap-slot constant moved to the header beside the array it sizes). They are grouped under one banner in arch_hw_internal.h so the next cut can see what this one cost: every line there is encapsulation given up to buy separation.

Verified by the boot, not by the compiler: all six mounts, all four filesystem images and the log sink read back exactly as before.

Next cuts, in the order they look worth doing

Measured, not estimated from section banners - which is what cut 2 taught:

sectionlinesgoes toblocked on
crash and wedge diagnostics~427arch_diag.cneeds an accessor for g_cpus
the Linux syscall table~693linux_syscall.c-
what a C runtime asks for~408with the syscall table-
the kernel log~434kernel/core/ — genuinely portable-

And one that is not a move at all: X-P2 steps 3 and 4. Demand paging removes the eager read, and with it g_exec_elf, g_interp_elf, the staging cache and the copy loop around them - several hundred lines that leave without needing a seam, because they stop existing.

The log is the only one that can become genuinely portable rather than merely a separate file in the same layer. The others stay architecture-adjacent, and that is fine: a 700-line file that does one thing is not the problem this plan exists to solve.

Boot Repeatability Progress

Status: 24 local boots clean out of 24 - but CI still reproduces the copy-on-write stress defect on a build containing every fix below, so this number is not the whole picture. See the correction dated 2026-09-02, from a long-standing ~1 in 8.

Six defects fixed. The first three were in the diagnostics built to hunt the failure rather than in the kernel they were watching. The last three came from CI and nightly logs rather than from another local sweep: a copy-on-write fast path deciding on a fact its compare-exchange could not guard, a kernel address handed to ring 3 as AT_BASE, and - the one that explains the rest - a panic that stopped one core instead of the machine.

24/24 is a good number and not a proof. At the rate this measured immediately before (23/24), a clean run of 24 happens about a third of the time by luck alone. What is stronger than the count is that two of the three new fixes are asserted deterministically - a host test for the copy-on-write window, a ring-3 assertion for AT_BASE - and the third turns any future kernel fault from a silence into a named panic. Last review: 2026-09-01

What fails

Two signatures, both intermittent, on an otherwise green build:

  • An instruction fetch from a page that is not present, with cr2 equal to rip, inside the same page the process was already executing from. Seen in svc-flap, a service whose whole body is a write and an exit - so the page vanished under a running process rather than the program jumping somewhere wrong. The boot gate reports it as deliberate_ring3_faults=2_expected=1,unexpected_cpu_fault.
  • A wedge, at varying phases, reported as missing:CLI_READY after 45 seconds of silence.

Why it is filed separately

It was found while trying to certify phase P2 of the memory-manager rewrite, and for a day it was assumed to be that phase's debt. It is not, and the evidence is unambiguous in two independent directions.

Locally, the same 24-boot measurement was taken at three revisions:

revisionwhat it isclean
f45ab1dthe commit before P2 started22/24
a714dbeP2 step 222/24
6510827P2 complete21-22/24

In CI, the nightly Boot Smoke x5 (flakiness gate) job - five boots, all of which must pass, which is this same criterion in miniature - has failed 27 times out of 31 since the workflow was created on 2026-08-02, including nineteen consecutive nights on 19ebeb74, a commit that predates any of this work.

So the failure is older than the subsystem it was being blamed on, and older than the gate that reports it.

What it is not

Not the premature-free family. Across these runs the free-while-mapped detector is silent, the stress service reports no defect, and frames_leaked, frames_double_put and poison_hits are all zero. Those were the symptoms of the three concurrency defects fixed during P2 (e02d9dd, 6510827), and they are gone: the boot rate went from 27/48 while those were live back to the ~90% that every revision measured before them.

What this cost, and the lesson

Phase P2's completion criterion was written as "48 boots with no failure". With a background failure rate near 8% per boot, the probability of 48 consecutive clean boots is about 0.02% - so that criterion could never have been met, by this revision or by any revision in the project's history. It was measuring the background, not the phase.

Two days were spent treating an unreachable number as a verdict on the work in front of it. The rule worth keeping: a criterion has to be checked against the baseline before it is used to judge a change. One measurement of the parent commit would have said so immediately, and it was not taken until somebody asked for it.

The number was not wasted, though, and that is the other half of the lesson. Insisting on 48 boots surfaced three genuine concurrency defects that a 16-boot gate would have passed over. The mistake was in what the number was allowed to conclude, not in running it.

What the walk dump found (2026-08-31)

A temporary probe printed the four entries of the page-table walk on any fatal not-present user fault. It answered the question the trap dump cannot:

va=0x8000001243 cr3=0x231f000 pml4e=0x040326e0 pdpte=0 pde=0 pte=0
stopped_at_level=1

The leaf is not missing - PML4 slot 1 is, so the whole user window of a running process is gone. And the value sitting in that slot is not anything this code writes: the present bit is clear and bit 7 (PS) is set, which is illegal at that level. Read as data rather than as an entry, 0x040326e0 is a pointer into the kernel image. The same shape appeared at three different cr3 values across boots (0x040326e0, 0x040336c0, 0x040336f0).

So a process page table is being freed and handed to some kernel allocation that writes a pointer into it, while a task still runs on that CR3.

This also explains why every memory detector stayed silent through the hunt: they watch frames mapped as leaves, and a page table is not a leaf. The free-while-mapped check could not have caught this by construction.

One candidate was fixed and was not it. execve destroyed the outgoing address space unconditionally, while hw_task_exit has asked hw_aspace_still_shared since the wedge it was written for - so a threaded process that execs freed tables its siblings were still running on. That is a real defect and the fix is in (hw_aspace_shared_by_other, asked by address space so exec and exit cannot answer differently), together with a leak in fork's error paths, which published a task slot as free without destroying the address space it had already created. Neither closed this signature.

What it actually was (2026-08-31)

Three defects, found in one afternoon by reading failure logs instead of re-running boots. Every one of them was in the instrumentation.

1. The "who freed it" tag was a use-after-free. hw_free_page_why released a frame and then wrote the freeing function's name into word 1 of the page. Between the release and that store another core can allocate the frame - and word 1 of a page is slot 1 of a PML4, the user window. A diagnostic meant to say who let go of a page was reaching into a live address space and replacing its entire user half with a pointer to a string literal.

That is the signature recorded above and never explained: a ring-3 instruction fetch faulting inside the page the program was already executing from, the walk stopping at level one, a kernel pointer where a page-directory pointer belonged. The dump that finally caught it printed the missing half - cr3_state=page-table, cr3_owners=1: the frame was not free at all. A live, owned PML4 with garbage in one slot.

The comment that had justified the write is worth keeping in mind. It reasoned carefully about the poison check never probing word 1, and not at all about the frame being reallocated in between. Being careful about the wrong hazard reads exactly like being careful.

2. The free-while-mapped walk was panicking the kernel. It reads every live task's page tables without the scheduler lock - deliberately, because it runs on the frame layer's release path - so it can read a table another core has just freed. A freed frame handed out again holds arbitrary bytes, some with the present bit set and an address field pointing anywhere. Following one faulted the machine, twice in twenty-four boots, and those failures were being counted as the defect it was hunting. Every step is bounded now.

3. The region pool had no lock. Eight refused inserts in a boot whose peak usage was twenty-nine descriptors out of two thousand - a pool nowhere near full that had shredded its own free list. Two allocations racing both take the head and both advance it. fork then failed with ENOMEM and the stress service reported "fork for cow". The same mistake the frame layer had already made and fixed, repeated in a layer written the day before.

What remains

One wedge in twenty-four boots, and it has a lead rather than a mystery:

#GP at vibeos_x86_64_task_enter (isr.S:294)
then #GP at rip=0xe4e4e4e4e4e4e4e4

A task's saved register context contains a repeated fill byte, so restoring it faults - and the panic handler then runs on the broken state and faults again. 0xe4 appears nowhere in the source as a constant, so the next question is what writes it: an uninitialised context, a recycled slot scheduled before its creator finished filling it in, or a kernel stack being used after it was freed. The task state machine's SETUP state and its tenancy counter exist for exactly this shape and should be the first things consulted.

The remaining wedge was virtio-net transmitting without a lock (2026-09-01)

Named by the wedge report rather than guessed at, which is what that tool is for. A boot that went quiet at busybox_cat left this:

CPU#0 rip=... vibeos_x86_64_virtio_net_send virtio_net.c:276
CPU#2 rip=... hw_spin_lock  <- from hw_sys_munmap
CPU#3 idle

and QEMU said the rest on its own stderr:

qemu-system-x86_64: Guest says index 65535 is available

virtio_net.c had no lock on the transmit path. Every send uses one staging buffer, one descriptor - g_tx.desc[0] - and one g_tx.last_used, and publishes with a plain g_tx.avail->idx++ on a 16-bit field.

This is the same defect virtio-blk had, in the same shape, never applied here. Two cores sending at once do not race over a window, they overwrite each other: one core's frame lands in the other's descriptor, the available index skips or repeats - 65535 is what a lost increment on a uint16_t produces - and whichever core reads the used index first leaves the other spinning on a completion that has already been consumed. The spin has a bound, but 50 million pauses is far longer than the gate's quiet budget, so it presents as a wedge rather than as the timeout it eventually becomes.

Interrupts stay on, as in the block driver: nothing in an interrupt handler transmits, so this lock can never be wanted from one. The timeout path releases it before returning, because holding it there would turn one late frame into a permanently dead network.

Fifth layer in this project to have shipped without a lock, after the frame allocator, the region pool, the page cache and the block driver.

Worth separating from it: the crash record that appears in every boot is svc-crash dereferencing null on purpose, to prove a ring-3 fault kills the task and not the machine. It is a gate, not a defect, and its absence would be the failure.

The copy-on-write failure is a copy that was replaced (2026-09-03)

Deduced from four CI logs, without a new run, and it narrows the search from three causes to one.

The child always reads exactly the parent's value, never garbage: found 0xd7 expected 0x28 with after = 0x28 means before = 0xd7. Four logs, four times, the same relationship.

And op_cow checks the parent's page separately, after reaping the child - "the child's writes reached the parent". That check has never fired.

Put together: the parent's page is intact, so the child was not writing into it, so the child did have a private frame. And the private frame holds a clean copy of the original, so the child's writes are gone from it.

That is not "the copy never happened". It is "the copy was made and then replaced." One of the three causes, chosen by evidence already in hand.

What that implicates: only clone_one - fork - sets the copy-on-write bit, and the child of op_cow never forks. So something is putting that bit back on a page that had already been made private, and the next fault copies the original over the child's work.

vibeos_pageinfo answers this directly: svc-stress now samples the frame before the fork, after it, after the child's write and at the check, and prints which of the three cases it was. That output needs a push to reach CI - the artifacts analysed here predate it.

The 0xe4 fault is not attributed to anything (2026-09-02)

Nothing in this file explains it, and three attempts to say otherwise were wrong. Recorded plainly so the next attempt does not start from a conclusion.

rip=0x57da77  cr2=0xe4  err=0x5   (user read, present, supervisor-only)

What is known:

  • It appears in two different programs, BUSYBOX.ELF and THREADS.ELF, at the same rip. Both are static musl binaries, so that address is almost certainly a musl internal function rather than program code - which is why the same number turns up in unrelated programs.
  • The faulting access is a read of a near-null pointer plus 0xe4. So a pointer that should have been valid came back as zero, or nearly.
  • It is deterministic in its path - the same rip, the same offset, every time - and rare in occurrence, a few times in twenty-four boots.
  • It appears with the X-P2 cache mapping on and off.

What is not known: anything about the cause.

The three wrong conclusions, kept because the pattern is the lesson:

  1. "The signature is gone" - from grepping the rip in a boot that had wedged and had no crash record at all, so the rip could not have appeared.
  2. "The cache mapping causes it" - from 21/24 twice against a baseline reported as better than it was.
  3. "Turning the mapping off removes it" - from one clean run of twelve, against a defect that appears a few times in twenty-four.

Every one of them read an absence over too short a run as proof. The next attempt needs the faulting instruction disassembled out of the binary the task was running, not another ratio.

A panic stopped a core, not the machine (2026-09-01)

This is the explanation for the silent-wedge family, and it had been visible in every log all along.

hw_panic ended with for (;;) { cli; hlt; } - which stops the calling core. Its own message said so: "halting on cpu 2". For weeks that line was read as though it said "halting".

The other three cores carried on. The log kept moving. The machine went quiet some seconds later, at a place with no connection to the fault, and every investigation started from that place. That is why so many of them ended at a plausible-looking pointer with no mechanism behind it: the evidence and the failure were separated by however long it took the survivors to need the core that had died.

A nightly log showed it plainly once the question was asked: a ring-0 #GP on cpu 2 with action=PANIC, followed by four hundred more lines of healthy boot, then silence.

The fix. A panic sets a flag and every core parks itself on its next interrupt. Deliberately not an IPI: every core already takes a timer interrupt a hundred times a second, so this needs no new vector, no acknowledgement protocol and no timeout to get wrong. A core inside a syscall with interrupts masked parks when it returns to ring 3; a core that never returns was stuck either way.

Proved by causing one. cpu 3 panics, and cpus 1, 2 and 3 report parking.

The gate now calls it a panic, not a wedge, and carries the reason:

reason=missing:<why> (waiting for CLI_READY) verdict=guest_panicked

Calling a panic a wedge sent the next person looking for a hang. That mattered much more than it sounds, for exactly as long as a panic left three cores running.

A kernel address handed to ring 3 as AT_BASE (2026-09-01)

Found from the nightly's artifacts, which carry the kernel ELF and the guest binaries - so a faulting address could be symbolised against the binary the task was actually running, which is the rule this file has been repeating without being able to follow it.

PIE.ELF faulted at 0x4046220 in _start_c (rcrt1.c:139), musl's self-relocation for a static position-independent binary. nm on the kernel places that address inside g_kernel_log.

p->interp_base was written in exactly one place - the branch that maps an interpreter - and read unconditionally when the startup block was built. A program without an interpreter therefore shipped whatever was already in that field to ring 3 as AT_BASE: uninitialised kernel stack for an execve (hw_proc_t np is a local), or the previous tenant's value for a recycled task slot. A static PIE relocates itself against AT_BASE, so musl took a stray kernel pointer as its load address and dereferenced it.

Intermittent for the honest reason that it depends on what was left on the stack - which is why it read as "position_independent_binary_did_not_run" some nights and nothing at all on others. It is also a kernel address disclosure to ring 3 whatever the program does with it.

Zeroed in hw_proc_create, which owns the contract - "every caller must remember to clear a field" holds until somebody adds a caller. The ring-3 ABI self-test now asserts AT_BASE is absent for a program with no interpreter, every boot, deterministically.

The copy-on-write defect: one window closed, the defect still open (2026-09-02)

Correction. This section previously said "found and closed". It was not.

A nightly on a build that contains the fix - its exec line carries cache_audit_checked, which only exists from d9c0049 - failed again with the same signature:

STRESS_FAIL: the child's own copy-on-write page at offset 0: found 0x07 expected 0xf8

after is 0xf8, so before is 0x07: the child read the parent's value after writing its own across the whole page. And COW_STATS reported exclusive_lost=0, so the window fixed below was never even entered in that boot.

The window was real - the host test proves it deterministically, and it stays - but it was not this one.

"Closed" was inferred from 24 boots with no stress failure. That is an inference from an absence, on a defect this same file records as appearing about once in twenty-four. This document exists largely to record what that habit costs, and it happened again here.

What the evidence says now, across three occurrences: the child writes after to all 4096 bytes and then reads before back at offset 0. That is not a lost write - it is the page being replaced by a fresh copy of the original after the child had already written into it. So the question for the next attempt is not "which write was lost" but what resolves a fault on that page a second time, and how the entry still looks copy-on-write when it does.

Replay seeds recorded: 76419299086, 97300336535.

The window that was closed (2026-09-01)

Closed by CI logs, not by another boot sweep. The nightly's clang/Release job had failed with

STRESS_FAIL: the child's own copy-on-write page at offset 0: found 0xf3 expected 0x0c
STRESS_FAIL: replay with EFI/BOOT/SVC_STRS.ELF 5498867...

on a build that predates all of this session's work, which settled that it was pre-existing rather than a regression. Decoding the two numbers is what pointed at the mechanism: the stress service writes after to every byte of the page and reads it back, and found=0xf3 is exactly before - the parent's value. The child was reading the parent's frame after writing its own.

Two defects, one page of code

1. The fault handler's exclusivity check rests on a fact its compare-exchange cannot guard. vibeos_vmspace_fault has a fast path: if the frame has one owner, take the write permission back in place rather than copying. It reads vibeos_frame_owners(phys), then compare-exchanges the page-table entry.

The exchange guards the entry. The decision depends on the reference count. The entry does not encode the count, so the two are unrelated, and a fork on another core can take a reference in the window while leaving the entry byte for byte identical.

2. clone_one is what walks into that window. Its already-copy-on-write branch does no compare-exchange at all - it has nothing to change, since the entry is already marked - so it takes a reference on the frame and maps it into the child without touching the source entry. There is nothing for the fault handler's exchange to notice. It also read the entry with a plain load, in a file whose entire design is that every page-table word is read once and acted on atomically.

The result is a process holding a writable mapping of a frame another address space holds copy-on-write: one side's writes landing in the other's private page. When the frame is later reused, the same defect shows up instead as a page holding a value from a completely different round - which is the local failure, found 0x5b, a before value from another iteration.

The fix, and why it can stay fast

The fast path keeps its optimisation and re-checks after the store: if the frame gained an owner while the entry was being widened, the entry is put back and the copy path taken instead. Undoing is safe precisely because no TLB has seen the writable form - it is installed and withdrawn before any invlpg, and the faulting instruction has not been retried. clone_one now reads its entry atomically. cow_exclusive_lost counts the window being hit, because it was silently common and must never become unobservable again.

It is asserted deterministically, not through boots

This is the part worth keeping. A race that appears once in some number of boots cannot be gated by a boot, and every earlier claim in this file that such a thing was "fixed" rested on a handful of green runs.

vibeos_vmspace_set_race_hook is a seam that lets a host test occupy the window on purpose: it fires between the ownership decision and the store, and the test takes exactly the reference clone_one would. The assertion is that the frame shared during the fault is not the frame the address space can afterwards write. Removing the re-check turns it red with

FAIL:a frame shared during the fault was made writable in place

every time, on the host, in under a second.

Rate after the fix: 23/24, with no stress failure in any of them.

Superseded: the copy-on-write path (2026-08-31)

Separate from the two signatures above, and the more tractable of everything here because it has a detector that names it:

[MM] FREE_WHILE_MAPPED frame=0x237d000 still mapped by pid=0x5a
     during cow-fault mappers=0x2 owners=0x1

Two address spaces hold the frame and one reference is counted, so a get was lost rather than a put duplicated. Three concurrency defects in this path were found and fixed during P2 - publication order, release order, and two cores resolving one fault with a compare-exchange - and the rate improved from 27/48 to the background. This one is not closed. It appears roughly once in twenty-four boots, and it was reported as fixed once already on the strength of a clean 48-boot run, which at this rate proves little.

The detector is trustworthy now: it compares mappings against references after the release, so it is indifferent to a frame being reallocated under it - the two predicates it had before were wrong in opposite directions.

Next

  • Establish when this started. The nightly has never been reliably green, so the search goes back before 2026-08-02; scripts/dev/bisect-boot.sh takes a revision and a count and always restores the tree.
  • The svc-flap signature is the more tractable of the two: a page disappearing under a running process is a narrow claim, and the crash records (crash on the kernel console) already keep the registers and the executable name at the moment of the fault.
  • repeat-boot.sh keeps the serial log of every failed boot, so a run leaves evidence rather than a count.

A second intermittent family, found on 2026-09-11: the kernel runs garbage during THREADS

Recorded apart from the signatures above because it is not one of them, and mixing two intermittent failures into one rate is how this file once spent a day on the wrong defect.

What the failing boot showed

Boot 7 of a twelve-boot series, reason=missing:unrecoverable. Read from the kept log rather than counted:

  • A kernel-mode trap on cpu 2 with a frame that is not a frame: rip=0xffffffff cs=0, and rsp/ss that decode as ASCII - TMP. and ES.TXT, file names from the boot's own self-test script. The core took an exception on a kernel stack holding the contents of a file-name buffer.
  • A second trap executing at rip=0x3030303030303030: the string 00000000. A return address overwritten with text.
  • Three cores on THREADS.ELF, one address space. THREADS_STAGE1_OK printed, THREADS_OK not - so it died in the stage where four workers contend a mutex and exit, and the last lines before the panic are munmap releasing their thread stacks.
  • The ring-3 fault at rip=0x800000016b earlier in the same log is svc-crash faulting on purpose. It is in every boot, green ones included, and is not the cause. It was mistaken for one once already today.

Why it is recorded as pre-existing

argv-hunt/serial-9.log, dated 2026-09-08 - three days before the process-state change and everything after it - has the same shape: a kernel-mode fault executing a garbage address (rip=0x44), three cores on THREADS.ELF sharing one cr3, THREADS_STAGE1_OK reached and THREADS_OK not. The stage it dies in exercises neither wait4 nor tkill, which were the change under test.

What is not known

The mechanism. The shape - a kernel stack whose contents became file data, in a stage full of thread exits and stack unmaps - is the shape of a kernel stack page reused while still in use, which is the family dead_kstack_base closed once already. That is a direction to instrument, not a finding. Rate: one in twelve on this series; the three-day-old log says it was there before, not how often.

A user-side sign, found later the same day

The twelve boots after the futex change failed twice, and both logs carry a line the first reading passed over, a user write the fault handler refused just before the trouble starts:

COW_FAULT va=0x406d10 rip=0x405982 err=0x7 NOT-handled   (THREADS.ELF)

Boot 9 stopped there - the thread was killed, THREADS_OK still printed, and the boot ended missing:CLI_READY after a POISON_BROKEN report. Boot 1 went on to the kernel garbage above. fail-7.log, from before the futex change, has the same line at va=0x4069f0 rip=0x405662: both addresses exactly 0x320 lower, the size of the stage added in between. One instruction, three failures.

Resolved against the binary that ran (scripts/dev/.resolve-threads.sh):

  • rip is musl's start in pthread_create, lock cmpxchg %edx,0x10(%rdi) - the new thread's first touch of the argument block its creator placed on the new stack. So rdi was 0x406d00.
  • 0x406d00 is inside __clone: the instruction after call *%r9. __clone hands the child rdi with pop %rdi off the new stack top.

So the child popped a return address - one pushed by a thread that had already started on that same stack and called its start function. When this thread began, the frame at its stack top was not the block its creator wrote but one another thread had left there.

That is a finding about the user side and a direction for the kernel side, not yet a mechanism: a thread stack mapped over memory still in use (an mmap range handed out twice, or a physical frame behind it reclaimed early - the POISON_BROKEN line in boot 9 points at the second) would produce it. What it does establish is that the kernel stack garbage is downstream: the first thing to go wrong is visible in ring 3, one instruction into a new thread.

Two probes for the user-side sign, and what they did not find

Both lived in the tree only for the measurement; the second is kept as .boot-evidence/probe-thread-stack-v2.diff.

  • Is a new thread's stack top a frame a live sibling also uses? Recorded at clone and compared across the thread group. Zero hits. One lesson from the first run of it: a probe line at WARN makes the gate fail every boot as kernel_reported_problems - eleven of twelve "failures" in that run were the probe's own lines. Probe at DEBUG or at ERROR only for what should fail.
  • Is a thread entered from its initial context after it has already run? Checked in hw_task_load_cpu_state: rsp and rip still the clone values, and the word at the stack top already the return address __clone pushes. Ten boots (probe-20260911-130736, every log kept): one hit, boot 5, pid 0x55 on its second entry. That thread did not crash and exited 0 - a thread truly restarted there would have taken the return address as its argument and faulted exactly as the signature does. So that load was most likely never returned to, and the mechanism is still not known. ctx is written in only three places: preemption in hw_schedule, and fork and clone.

The family recurred once more in the H-007 series with the same instruction (0x405b72 in that build, the same start as before) and the same panic.

The exit switch ran with interrupts on (fixed 2026-09-11)

What the probes above were circling. hw_task_exit chooses next, sets it RUNNING, marks it on_cpu and makes it the core's current task - and then keeps running the dying task's code for a while: address-space teardown, the process reference, the locks those take and release. Only after that does it jump into next.

If a timer interrupt lands in that stretch, hw_schedule sees next as the current task and does what it does for any preempted task: saves the interrupted frame as next's context, marks it READY and on_cpu = 0. That frame is the dying task's, on a kernel stack already parked for release. Another core then picks next up and resumes the dying task's exit code on that stack, while the first core's drain frees it. Every piece of the four-worker family fits: "this task's stack is the free-page poison", kernel frames made of ASCII, a core whose current task is a slot already reclaimed as SETUP, and - once - a running -> running transition by hw_task_exit.

The one question was whether interrupts could be on there, since syscall clears IF. They can: a task killed out of a wait that did sti; hlt reaches the exit with the flag still set. A probe at the switch counted 4 per boot, every one a process ended by SIGKILL or exit_group while blocked.

Gated as a mechanism, because the crash cannot be produced on demand: a must-be-zero counter, exit_switch_irq_on, on the [TASKS] MUSTBEZERO line and asserted by the gate. Red first - task_exit_switch_irq_on=4 on the unchanged kernel. The fix is one cli before the choice; nothing below turns interrupts back on, and the iretq into next restores its own flags. Counter 0, check.sh all green, twelve boots 12/12.

What is not yet shown: that this was the cause of the four-worker family. That family ran about one boot in eight to ten, so twelve clean boots is consistent with it and proves nothing about it. The counter proves the window is closed and will say so if it reopens; the series from here on will say whether the crashes stopped.

One slip on the way, recorded because the file already warns about it: the first build of the probe had a syntax error, .c5red.sh printed build-rc=1 and booted anyway, and twelve boots measured the previous kernel. It stops on a failed build now, and checks that the kernel image is newer than arch_hw.c.

The four-worker family recurred after the exit-window fix (2026-09-11)

The section above said the exit-window fix was not yet shown to end this family. It did not end it. Boot 7 of the series that verified H-009 (.boot-evidence/fail-20260911-154006-boot7.log) has the whole signature: THREADS after THREADS_STAGE1_OK, the unhandled write at rip=0x405b72 in musl's start, then "this task's stack is the free-page poison" and the machine stopped. exit_switch_irq_on was zero on that kernel.

So the window was a real defect and stays closed, and it was not the cause of this family - or not the only cause. What is still known about the mechanism: a new thread's first instruction finds a return address where its argument should be; no live sibling shares its stack frame; once, an initial context was re-entered without the thread crashing. The family is open again in the review tracker.

The gate that hung for 48 minutes (fixed 2026-09-11)

A boot in a probe run held the whole series for 48 minutes on a guest that had panicked. The wedge report taken from the still-running emulator had every core in the panic, fault or serial path. wait_for and the wedge report are both bounded, which is why the cause took a second reading to find: it was the loop underneath them.

The serial pump read the socket until recv raised BlockingIOError - until the guest went quiet. A guest faulting inside its own panic path reprints trap lines as fast as the port carries them, so the pump never returned, and the deadline and the silence budget, both checked by its caller, never ran.

drain_available hands chunks on for at most 0.25 s and returns; what is still waiting is read on the next call. A module-level self-test feeds it a socket that is never empty with a clock that moves 0.1 s per read, and it must stop after a few chunks. Validated three ways: the gate compiles, its self-tests pass, and a copy with the budget check removed hangs at import (killed after ten seconds). The series that verified it included a crashed boot (.boot-evidence/fail-20260911-162643-boot12.log) that the gate ended in about a minute.

The probe that hid the defect it was looking for (2026-09-11)

A probe for the four-worker family recorded, at clone, the frame behind each new thread's stack top; counted how often a thread was loaded from its initial context; and printed one line at a probed thread's ring-3 fault saying which of three stories it was - the stack page replaced after clone, the thread started twice, or another thread writing its stack.

It never printed that line, because with it in the kernel the crash did not happen: 24 boots, then 36 more with a quieter version that logged nothing on the normal path. Without it, on the same kernel, 36 boots gave two crashes with the full signature (probe-20260911-174620-boot1 and -boot35: rip=0x405b72, va=0x406f00). Seventy clean boots at a rate of one in eighteen would happen by luck about 2% of the time - (17/18)^70.

That is a result rather than a failure. The quiet probe did two things on the path a crash needs: it added 28 bytes to every task, and - when a thread was loaded with its initial context - it walked the page tables to read the word at the stack top. Logging was not the perturbation. Slowing a thread's first start is enough to close the window, which puts the defect there and not in exit or in reclaim.

Code in the kernel cannot observe this without changing it. The next step is to look from outside: have the gate ask the QEMU monitor for every core's state at the moment a THREADS fault is reported, the way wedge_report.py already does for a silent guest.

Sharpened, and why in-kernel probing stopped (2026-09-11)

A probe that runs only at the ring-3 fault - so it cannot touch the window before it - reproduced the family (four in thirty-six) and printed nothing: the faulting thread never reaches hw_fault_kill_current_user. Reading the two traps that arrive together settles what is happening.

  • The thread's fault is a write to a text page: COW_FAULT va=0x406f00 rip=0x405b72, musl's start doing lock cmpxchg %edx,0x10(%rdi) with rdi pointing inside __clone (0x406cd1). A new thread's argument pointer is a code address, not its stack - the sign recorded above.
  • On another core, at the same moment, task 0x8 is entered with a garbage context: rip=0xffffffff, cs=0, and rsp/ss that decode as ASCII (TMP., ES.TXT) - file-name-buffer bytes. A task's saved registers, or the kernel stack they sit on, were freed and handed out as a file buffer and then scheduled. That is the halting free-page poison panic.

Both point at the same window: a thread's first schedule. That is exactly where the earlier probe did its one piece of work (a page walk when a task is loaded from its initial context), and slowing it there was enough to make the family vanish for seventy boots - so the defect is in context save/restore around a thread starting, not in exit (the interrupt-window fix, real, did not touch it) or in reclaim.

Why the investigation stops here rather than guessing. Every in-kernel probe tried changes the timing enough to hide the defect - the family runs about one boot in twelve to eighteen and disappears under any added work on that path. A fix written without a confirmed mechanism would be indistinguishable from the probe: it would make the crash rarer and prove nothing. The next tool is external: have the gate ask the QEMU monitor for every core's registers and stack at the instant a THREADS fault is reported, the way wedge_report.py already does for a silent guest - observation that adds no guest-side work. Kept apart from the review findings, which are all closed; this one is not.

Observing the four-worker family from outside (2026-09-11)

Since every in-guest probe masks this family, the gate now watches the serial stream for its marker - free-page poison, or the frame-level POISON_BROKEN, which appear only when a page was reused while still mapped, and never on a healthy boot (the null-deref svc-crash fault every boot carries is deliberately not a trigger). The instant the marker appears, and once per run, it asks the QEMU monitor for every core's registers and frame walk - the same wedge_report.py used for a silent guest - and files them in the summary's wedge_report= field, tagged THREAD-FAULT.

Because the poison panic parks every core, the snapshot is each core essentially at the fault, not the parked hlt state 45 seconds later that a timeout-driven report would show. It adds nothing to the guest: the one thing that reliably changes this family's timing is guest-side work on the thread-load path, and a monitor read is outside the guest entirely.

thread_fault_signature is a pure function with self-tests, including one that a detector keyed on the benign fault would trip. Verified: 24 boots through the changed gate were all green (no crash this run), so the snapshot has not yet fired on a real fault; the detector's self-tests are the standing proof, and it captures on the next crash.

Next, when a snapshot is in hand: the faulting thread's registers, and which task each core was running, at the fault - enough to say whether a sibling was mid-context-switch on the freed stack, without a single guest-side instruction.

The four-worker family, found and fixed (2026-09-17)

Closed at last, and the shape of it was not where three sessions of reading had looked. It is not the exit teardown freeing a stack, not the kstack drain, not reclaim. It is a task that runs on a core while its scheduler state says READY.

The reproducer that broke the stalemate. Every in-kernel probe masked this family because any added work on the thread-load path widened the window shut. The way past that was not another probe but a reproducer: musl_threads.c wraps its four-worker create/join round in a loop, THR_4WORKER_REPS (one by default, so the boot gate is unchanged; built at 30 for the hunt). Repeating the crashing path faithfully - not adding unrelated work - took the rate from about one boot in fifteen, and maskable, to two in five. From there the mechanism was caught in an afternoon instead of chased for a session.

The three detectors, and what each said. A guard in hw_free_kstack_pages that panics if any core's syscall_kstack_top/rsp0 is inside the range being freed; a guard in hw_task_release that panics if any core still has the slot as current_task; and the pre-existing hw_task_load_cpu_state cr3 guard (address space freed while still schedulable). All three fired on the same event: a core running a thread whose slot was being released and torn down under it, consistently ready_by=futex_wake. The clean caller, printed on the cores-quiesced path in hw_panic_cpu_summary rather than the interleaved raw one, was hw_task_exit releasing the dying slot while next == dying - the scheduler had picked the exiting task as the next to run.

The mechanism. A task blocked in hw_futex_wait is BLOCKED and still on_cpu until its first reschedule. A FUTEX_WAKE in that window sets it READY, and because it is still on its core it resumes in place - the hlt loop breaks and it keeps running - without going back through hw_schedule, which is the only thing that would have set it RUNNING again. So it runs with state READY. When it then calls hw_task_exit, the exit clears its on_cpu before it switches away (it keeps executing on its own stack until task_enter), and a READY, !on_cpu task is exactly what hw_pick_next returns - to this core as next == dying, or to another core outright. Two cores on one task, one of them freeing its stack, its address space and its slot. That is every signature this family ever produced.

The fix. One invariant, reasserted where it was broken: a task running on a core is RUNNING, never READY. hw_task_exit sets the exiting task RUNNING under g_sched_lock at entry, so it is never a scheduling candidate while it tears itself down. Verified with the reproducer: at THR_4WORKER_REPS=30, before the fix the family hit most boots and the guards fired; after it, 48 boots, zero crashes, zero guard fires - the guards going silent is mechanism-level proof, not a ratio. The hw_task_release guard is kept as a permanent gate: it is a cheap eight-core scan, it never fires on a healthy boot, and this class had just failed the gcc/Release CI smoke (address space freed while still schedulable, 2026-09-17), so a regression of it should stop the machine with the culprit named rather than surface as a one-in-fifteen wedge again.

Found in passing, fixed separately: the futex waiter table had a slot-index ABA of its own (H-007's class). A waiter records its task's slot; hw_futex_wake woke g_tasks[slot] after only a state == BLOCKED check, so a waiter reaped and its slot reused could have a wake land on the new tenant. Waiters now record alloc_seq and a wake requires it to still match. Not the four-worker cause - that was the READY-while-running invariant above - but a real defect the hunt surfaced.

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.

Boot Repeatability (as of 2026-09-29)

24 boots clean out of 24, from a long-standing 1 in 8. Six defects fixed: three were in the diagnostics rather than the kernel they watched, and three came from CI and nightly logs - a copy-on-write fast path deciding on a fact its compare-exchange could not guard, a kernel address handed to ring 3 as AT_BASE, and a panic that stopped one core instead of the machine, which is what separated every silent wedge from its cause. 24/24 is a good number and not a proof; what is stronger is that two of the three are asserted deterministically and the third turns a silence into a named panic

Bootloader Progress

Status: Completed (Phase 4 boot-to-CLI verified) Last review: 2026-08-01

Implemented

  • UEFI entry path and protocol wrappers in boot/uefi_main.c and boot/uefi_protocol.c.
  • Firmware discovery helpers (ACPI/SMBIOS and boot metadata) in boot/uefi_firmware.c.
  • Memory map normalization and ExitBootServices retry hardening in boot/uefi_memory.c and boot/uefi_boot_handoff.c.
  • Boot structure allocation fallback (AllocateAddress -> AllocateAnyPages) with cleanup-aware error handling.
  • Kernel image planning with ELF64 primary parser and PE32+ fallback (include/vibeos/bootloader.h, boot/bootloader_core.c, boot/uefi_pe_loader.c).
  • Real kernel load path from EFI filesystem (boot/uefi_file_io.c, boot/uefi_main.c) with primary/fallback file paths.
  • Segment layout validation before load (file range, size consistency, overlap checks) in boot/uefi_pe_loader.c.
  • Strict freestanding UEFI build path:
    • bootloader no longer links libc or embedded kernel objects
    • PE32+ fixup validates Subsystem=10, x86_64 machine type, non-stripped relocations, stack/heap reserve, and .reloc directory
    • BOOTX64.EFI is accepted and started by OVMF.
  • Real EFI media pipeline in build artifacts:
    • artifacts/efi_root/EFI/BOOT/BOOTX64.EFI
    • artifacts/efi_root/EFI/BOOT/VIBEOSKR.ELF
    • manifest + SHA256 integrity entries (cmake/create_boot_image.cmake).
  • Boot diagnostics expose phase tokens: BL_EFI_OK, BL_FS_OK, BL_PLAN_OK, BL_LOAD_OK, BL_EBS_OK, BOOT_OK, CLI_READY.
  • OVMF smoke automation:
    • Linux required gate (scripts/qemu-smoke-ovmf-linux.sh)
    • Linux/WSL boot-to-CLI gate (scripts/qemu-smoke-cli-linux.sh)
    • Windows smoke runner (scripts/run-qemu-ovmf.ps1).
  • CI gate model:
    • required: host tests + OVMF boot smoke + boot-to-CLI smoke
    • informational: direct-loader probe (-kernel) for compatibility telemetry.
  • Host-side parser regression coverage (PE + ELF) in tests/kernel/kernel_tests.c.
  • May 2 (commit 630d94c): Fixed serial I/O privilege detection for Clang inline asm (changed uint16_t cs → unsigned long, constraint "=r" → "=&r" with memory clobber) in kernel/arch/x86_64/serial.c.
  • May 3 (commit 0d4ba7f): Phase 4 security discovery infrastructure:
    • uefi_firmware_discover_security_settings() in boot/uefi_firmware.c discovers Secure Boot and Measured Boot capabilities
    • Firmware tags (types 3-4) created with Phase 4 default values
    • Integration in PHASE 2 (discovery) and PHASE 4 (application) boot stages
    • Security telemetry logged before kernel handoff
  • May 10 (commit 6391147, 29b81db): Fixed UEFI bootloader generation for Clang on Linux:
    • Added -fPIE and -fno-strict-aliasing compile flags for UEFI compatibility
    • Linked against libc and clang_rt.builtins-x86_64 on Linux targets
    • Removed unsupported --subsystem=10 flag from GNU ld (objcopy handles PE subsystem during conversion)
    • Added fallback support in objcopy conversion: efi-app-x86_64 (primary) → pei-x86-64 (fallback 1) → pe-i386 (fallback 2)
    • BOOTX64.EFI now generates correctly with Clang Release builds on Linux CI

Final Bootloader Checklist

  • Build pipeline emits bootloader.efi, vibeos_kernel.elf, legacy vibeos_boot.img, and EFI media root.
  • EFI media contains canonical fallback path EFI/BOOT/BOOTX64.EFI and kernel payload VIBEOSKR.ELF.
  • Bootloader loads kernel from filesystem (no fixed hardcoded load-address dependency).
  • Segment plan enforces structural safety checks before memory copy/load.
  • OVMF smoke path and logs are integrated in CI artifacts.

Verified Evidence

  • ctest --test-dir build-wsl --output-on-failure: 2/2 host tests passed.
  • ./scripts/qemu-smoke-ovmf-linux.sh build-wsl BOOT_OK 60: passed via OVMF primary profile.
  • bash scripts/qemu-smoke-cli-linux.sh build-wsl 90: passed, verified help, status, echo vibeos, and halt through the serial CLI.

Toolchain notes

  • Clang UEFI images: resolved (2026-07-22). Previously the Clang bootloader.efi, produced by the ELF→PE objcopy path, was rejected by OVMF at LoadImage (EFI_UNSUPPORTED, last_phase=none) — Clang's .data.rel.ro/.rela.data.rel.ro layout did not survive the objcopy conversion. Fixed by giving Clang a native PE link path: compile the bootloader with --target=x86_64-unknown-windows-msvc (MS x64 ABI matches UEFI) and link with -fuse-ld=lld -Wl,-subsystem:efi_application -Wl,-entry:efi_main -nostdlib, emitting bootloader.efi directly (no objcopy). Also removed a spurious <stdio.h> include from boot/uefi_serial.h so the bootloader is fully freestanding. GCC keeps the ELF→objcopy path unchanged. Both toolchains are now verified booting to CLI under QEMU+OVMF and both are boot-gated in CI.

Future Hardening

  • Phase 4.1: Secure Boot and Measured Boot policy path discovery via EFI GetVariable
    • Currently creates firmware tags with Phase 4 defaults (SecureBoot=0, MeasuredBoot=0)
    • TODO: Implement actual GetVariable calls to detect real firmware capabilities
    • Target: May 2026 sprint close
  • Phase 4.2: Richer fault telemetry around boot failures (handoff errors, security policy rejections).

Next checkpoint

  • Implement GetVariable integration for real Secure Boot detection in Phase 4.1.
  • Start the next kernel/userland milestone on top of the verified boot-to-CLI baseline.

The console lock, and why the machine goes quiet

Status: the bound is in and the counter is gated. The svc-press failure is now understood well enough to describe, and is not yet fixed.

The one lock that cannot report itself

Every other lock in this kernel panics when its bound fires. This one cannot: a panic prints, and printing goes through the very lock whose state is in question. So it takes the lock anyway, records that it did and who was holding it, and lets the machine carry on talking.

That trades a correct line for a possibly interleaved one, and it is the right trade twice over. Interleaving is detected — interleaved_lines is the boot gate's first check, precisely so a garbled line is caught and named. Silence is not detectable at all, and it is the failure where every piece of evidence is missing rather than wrong.

[CONSOLE] MUSTBEZERO bad_unlocks=… stuck=… stuck_owner=… is the line; the gate fails on a non-zero count and on the line being absent.

What it explained

svc-press allocates 256 KiB at a time and touches every page. At a hundred and twelve blocks — twenty-eight megabytes, not the twenty this was written up as — the machine goes completely quiet.

Four explanations were excluded first: not a deadlock on hw_spin_lock (the bound was lowered to twenty million and still did not fire), not memory exhaustion (104,572 free frames down to 98,735 of 107,485, with reclaim never running), not reverse-map exhaustion (seven thousand nodes against two hundred and fifteen thousand), and no fault of its own.

Lowering this bound to ten million changed the machine from silent to talkative, and what came out was a hw_ctx_check report — cr3_by=, exe=, rip=0x0, cs=0x0, rflags=0x0 — a task whose saved context is zeroed, refused entry by the guard that exists to catch exactly that.

It came out interleaved, because taking a held lock mid-line is what this bound does. That is the cost, and it bought the first piece of evidence this failure has produced in its existence.

So the shape is: a task acquires a zeroed context, hw_ctx_check refuses to enter it and reports under the console lock, and the machine stops there — with the console lock held. Everything else then piles up behind it, which is why the symptom is silence rather than a panic.

The original wedge report said this and was not believed. It put CPU#2 and CPU#3 in hw_ctx_check and CPU#0 and CPU#1 at an address whose nearest preceding symbol was hw_task_slots — a function that returns a constant. hw_ctx_check was right; the other two are almost certainly the pause loop in vibeos_x86_64_serial_lock, which links early and therefore sits at a low address. The lesson CLAUDE.md draws about nearest-preceding-symbol naming cuts both ways: a name that looks absurd is worth discarding, and a name that looks plausible is worth checking rather than dismissing wholesale.

What is not done

Why a task's context is zeroed under heavy anonymous mmap. That is the actual defect and it is untouched.

And hw_ctx_check prints a dozen fields from the task table, including exe_path and vibeos_task_last_why(slot), while holding the console lock with interrupts masked. If any of those reads faults — plausible, since the task it is describing is already corrupt — the machine stops inside the critical section, which is exactly the observed state. Making the report safe to run against a task that is known to be broken is the next step, before the cause is chased.


The reporter that could not report

Written after the bound above found its first real failure, and after the tool built to explain it turned out to have the same defect it was looking for.

hw_lock_deadlock announced a stuck spinlock by calling serial_puts, which takes the console lock. Under svc-press the console lock is one of the jammed ones, so the core that had detected the deadlock sat down in vibeos_x86_64_serial_lock and the message never arrived. The wedge report caught it in the act: CPU#3 rip=0x4000cb5 hw_lock_deadlock.

A lock reporter that can only speak while the locks are healthy reports nothing on exactly the boots it exists for. It writes raw bytes now, taking nothing. The output can interleave, which is the correct trade for the same reason the console bound takes the lock rather than waiting: interleaving is caught by the gate's first check, and silence is not caught by anything.

What svc-press actually does

Not a wedge, or not only one. With the reporting unjammed the boot gate reports guest_still_talking for thirty-nine minutes with quiet_for=0: the machine emits 7.5 MB of hw_ctx_check dumps and never stops. Both verdicts have been seen from the same workload, and which one appears depends on whether the console lock jams before or after the flood starts. That is why this failure was written up for so long as "the serial log fills with binary" - it is not binary, it is one diagnostic repeating for ever, cut into fragments by cores writing over each other.

The repeated fragments are the tail of that dump, and they are not random: rax=0x10a030c050e07000, rbp=0x10a030c010e07200, rax=0x60f08010a030c050. The high nibbles step by 0x20 - 10, 30, 50, 70, 90, a0, c0, e0 - so whatever the failing context is being read from holds structured data, not the leftovers of a freed page. That is a different question from "a context was zeroed", which is what the earlier, garbled capture suggested, and both readings are recorded here because the difference decides where to look next.

Where this stands

The characterisation is much sharper than it was and the instruments now work, which is the whole of what this stretch bought:

  • the console lock cannot silence the machine any more, and says when it was taken by force;
  • the spinlock bound fires and can speak while the locks are jammed;
  • the trap dump reports what it did rather than what the model proposed;
  • an execve that fails names its reason.

The defect itself is not fixed, and it predates all of this. It is outside the memory-manager plan - it is not any of P0 through P7 - and what it blocks is narrow and stated in mm_swap_file.md: freed_anon stays zero because nothing else runs this machine short enough of memory to make reclaim evict a page.

C1's last two checks, and what measuring cost the plan

check-subsystem.py and check-blast-radius.py are the two C1 asked for and nobody had written. Both are wired into check.sh all, both are ratcheted at today's measurement, and both have been broken on purpose and seen red (scripts/dev/cases/core-contract.txt).

The interesting part is not that they exist. It is that each one contradicted the document that asked for it, on its first run.

check-blast-radius.py: the plan's progress metric was folklore

docs/core/ says, in three separate files, that blast radius is the real measure of this refactor — chosen precisely because line count had been the stated criterion twice and failed twice. And the table it gave was:

a filesystem — 1, vibeos_storage_register a block driver — 1, vibeos_blk_register everything else — arch_hw.c

That table was written from the existence of two registries, not from a measurement. Here is the measurement:

extension pointradiusthe files
filesystem, the way four of five arrived4storage.c, storage.h, io_bringup.c, source list
filesystem through the registry (FAT)4arch_hw.c, arch_x86_64.h, io_bringup.c, source list
block driver4arch_hw.c, arch_x86_64.h, kmain.c, source list
network interface4arch_hw.c, arch_x86_64.h, kmain.c, source list
display3arch_hw.c, serial.c, source list
input device2arch_hw.c, source list

Nothing is 1. The floor is 2.

The four filesystems were never evidence of anything

The claim rested on "four filesystems arrived without their layers being touched". They did not. ext2, ntfs, exfat and iso9660 are named directly in g_probes[] in kernel/fs/storage.c, each with a mount wrapper beside it and a member in vibeos_volume_t. vibeos_storage_register has one caller in the whole tree, and it is FAT.

And the registry buys nothing yet, which is the finding worth keeping

FAT — the one driver that does use the seam — measures 4 as well. Somebody still calls the register function from arch_hw.c, declares it in arch_x86_64.h, and mounts through it in io_bringup.c.

A registry whose members still need a bring-up call has the same blast radius as no registry at all.

That is a much sharper target for C6 than "move code out of arch_hw.c", and it was not sayable before the number existed.

The check corrected its author too

FAT was hand-measured at 2 while the check was being written, by grepping vibeos_x86_64_fat_vfs — which misses _ops and _register_driver, the two symbols that are the seam. The first run said 4 and the declared number was wrong. The same run also caught a matcher bug: a shared symbol prefix is not a module, and vibeos_x86_64_fat_ matches both the VFS driver and the raw FAT reader, two different things, giving a radius of 7 for a module whose answer is 4. The matcher is a regex per point now.

check-subsystem.py: one property was already perfect, and three are debts

50 modules, four mechanical properties out of the seven in docs/core/architecture.md.

propertytoday
exported_state — state has a name another file can write0
no_header15
state_without_lock3
no_case35

exported_state=0 is worth stating rather than assuming. It is the one part of the module contract this tree satisfies everywhere without having been told to, and it is the property the other three rest on. It is also measured with nm rather than a regex — lowercase symbol class is a static, uppercase is not, and the linker's opinion is the only one that counts. That is why its sabotage case is the one worth having: a property green across the board is indistinguishable from a property nothing measures.

The lock property had to be narrowed, and the first version was noise

It began as "does the module register a lock", which is what architecture.md lists. That reported 29 of 50, including blkdev.c — which holds its own lock. A detector that flags a module doing the right thing is a detector people learn to ignore, and this file has already recorded one that reported 3,019 use-after-frees in a boot with none.

Narrowed to the shape that actually cost months — mutable file-scope state and no mention of a lock anywhere in the file — it reports 3:

  • kernel/fs/vfs.c — the mount table
  • kernel/mm/anon.c — a clock hand
  • kernel/sched/forkguard.c — the fork budget

None of these is claimed to be a defect; each may be correct under a caller's lock. They are three places where "is this serialised?" has no answer in the file. backing.c was exactly this shape, was correct for months on the accident of having one caller, and the second caller returned one file's pages under another file's key.

Two of the seven parts are deliberately absent

The must-be-zero counter is C2's step 3, which adds the counters before the check that demands them. Writing it now would ship a check red against thirty modules, and this project already knows what that produces: check-mm-layering.sh sat failing and unread for a phase. An init that names its reason stays in review, and the script says so instead of implying coverage.

What this does to the plan

Three documents were corrected rather than appended to: README.md, architecture.md and invariants.md all claimed a radius of 1, and phases.md's C1 step 2 asked for a baseline it had already guessed wrong.

The pattern is the one this project keeps producing and has now caught in its own planning documents: a number that is written and never read. The plan named blast radius the progress metric, gave it a value, and nothing measured it for the entire time it was the metric.

C1: the checks that watch the contract

First instalment of the core plan's C1. Two checks now run in check.sh all that did not, and the first finding was about the plan's own author.

check-reachable.py was written and never wired in

It was committed two days before this entry and check.sh never called it. That is "configured and consulted by nobody" — the defect docs/core/ names as the one this project produces most often, committed inside the document that names it, by the person who wrote that sentence down.

A check nothing runs and a check that passes are the same thing from outside. This is the fourth instance recorded here and the first where the mechanism was a check rather than a kernel feature, which makes it worse: the whole value of a ratchet is that somebody would notice it going red.

It reports unreached=12 baseline=27 and runs on every check.sh all now.

check-chokepoints.py, and how measuring changed its design

The plan specified it as: each security check declares how many call sites it has, so that a second one is noticed — the failure where a handler is copied, its guard is copied, and the two drift. C4 moves 4,169 lines of syscall handlers out of arch_hw.c, which is exactly that operation.

Counting the real numbers changed the shape of the check:

guardsites
hw_user_range_ok44
hw_user_range_why6
hw_signal_permitted5
hw_task_alloc_guarded3
hw_user_addr_ok2

hw_user_range_ok has forty-four because every syscall taking a user pointer validates through it. For that guard the dangerous direction is the count going down: a syscall that quietly stops validating is a hole, and it reads like a simplification. Nothing else in the tree would see it.

So the check asserts the declared number in both directions. It is not a prohibition — adding a syscall legitimately moves the count, and the fix is to edit the number in the same commit. Same rule this project already applies to the mm-layering allowance: raise a limit as a decision, not as a reflex.

It caught its author first

The declared counts were set from grep over kernel/arch/x86_64/*.c; the check walks all of kernel/. Two of the five numbers were wrong on the first run — hw_user_range_ok also has sites in linux_socket.c and linux_signal.c, and hw_task_alloc_guarded had one fewer than assumed. The numbers are the measurement now.

Sabotage, both directions

unmodified                              chokepoints=ok watched=5
one hw_user_range_ok site removed       FEWER than declared   → red
a second hw_signal_permitted site       more than declared    → red
restored                                chokepoints=ok watched=5

Cases in scripts/dev/cases/core-chokepoints.txt, including the honest failure mode of every ratchet here: editing the declared number to match instead of looking at what moved, which turns the check off for that guard and looks green.

What it cannot judge

It counts textual occurrences, not calls: a name in a comment counts, a call through a function pointer does not. Every guard here is called by name today. Stated rather than worked around, for the same reason check-reachable.py asks "is this named anywhere" instead of building a call graph it would have to fake: a confidently wrong answer is worse than a coarse one.

Still to come in C1

  • check-subsystem.py — the seven parts of a module, ratcheted.
  • check-blast-radius.py — files touched to add a filesystem (1), a block driver (1), a character device, an input device, a network interface, a display, a syscall, an ABI (arch_hw.c for all six). That table is the plan's real progress metric and it does not exist yet.

C2 opens by finding that its own strongest word meant nothing

C2's objective is that every module says whether it is broken. Step 1 is to enumerate what exists, and the enumeration produced a defect before it produced a list.

The count

report tags the kernel prints33
lines carrying MUSTBEZERO6
of those, read by the boot gate5

MUSTBEZERO is the strongest claim this kernel makes about itself. The word means the boot gate fails if the number is not zero — that is the whole contract, and it is why a reader trusts the word instead of checking.

[GUI] MUSTBEZERO guard_broken= did not have it. Printed on every boot for the whole of C0, read by nobody.

Who wrote it, and why that is the point

It was added during C0's GUI work, in the session that had just quoted CLAUDE.md's rule at it:

A line in the serial log is not a check. The ring-3 ABI self-test printed "abi: mmap/mprotect/munmap wrong" for an entire session while the boot gate stayed green.

Knowing the rule, having just written it down, and walking into it anyway is the argument for mechanising it. Judgement did not survive one session.

Why neither existing check-on-checks saw it

Two already watch the checks, and this fell exactly between them:

  • check-counters-produced.py asks whether anything can move a reported counter. g_gui_guard_broken has a ++, so it was satisfied. It exists because VIBEOS_BLK_TIMEOUT was defined, printed, asserted and produced by no driver — the mirror image of this defect.
  • check-assertions-covered.py matches every reason= the gate can emit against the sabotage cases. guard_broken never appeared in a reason=, because that is precisely what was missing, so there was nothing to match.

A counter that is produced and never asserted satisfies both. That is the third axis, and check-mustbezero-asserted.py is it: every MUSTBEZERO <name>= string literal under kernel/, against every such pattern the gate searches for. It reported unread=1 on its first run against the real tree — not a simulation, the state the repository was already in.

It cannot tell whether the gate, having matched a name, compares it to zero. That is one line below the regex and stays in review; the file says so rather than implying coverage.

What was fixed, and what was recorded instead of fixed

The gate asserts gui_guard_broken now, and both cases in gui-guard.txt have been run. Writing one guard word of the back buffer as zero took the boot from status=pass to reason=invariant_failed:gui_guard_broken=1 - red for the right reason, and counting the one word that was broken rather than the whole buffer. Deleting the assertion again, with the kernel still printing the line, takes check-mustbezero-asserted.py red; that case did not need simulating, because it was the state the repository was already in.

Recorded and not fixed: the report line is assembled from twelve separate serial_puts calls with no serial_lock around them. By this project's own rule that is twelve critical sections, not one line, and CLAUDE.md records what it costs — a hex field cut off after its 0x, and two investigations into crashes that were a marker cut in half. It has not misread yet because the report runs late, when little else is printing. That is an accident of timing, not a property; it is the same sentence already written here about a page cache with one caller. Bracketing it is a change to the log path and belongs with the rest of C2, not with an assertion fix.

What C2 still has to do

The 33 tags against 6 must-be-zero lines is the real gap, and step 2 is unchanged: for each module with none, name the harm, add the counter, print it on the module's tag, assert it, and demonstrate it can be non-zero before concluding anything from a zero. check-subsystem.py gains the must-be-zero property at the end of that, not before it — a check shipped red against thirty modules is the state check-mm-layering.sh sat in for a phase.

Second finding, and this one had a comment vouching for it

hw_cache_audit compares every resident page-cache page against the bytes its file actually holds, and reports cache_audit_checked / cache_audit_changed on the [EXEC] line. It runs on every boot. On the boot this was found it compared 1821 pages.

The boot gate did not read the result — and 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. That is a different defect from the GUI counter found an hour earlier: that one was silent, this one was vouched for.

Why the check added an hour earlier could not see it

check-mustbezero-asserted.py knows counters that carry the word MUSTBEZERO. This one did not carry it. So the check written specifically for this shape, against a tree that contained a second instance of the shape, reported ok.

That is worth stating plainly rather than filing as bad luck: the check mechanises a word, not a property. A counter opts into the guarantee by spelling it, and one that never spells it is exactly as unwatched as before. The fix for this instance is to spell it — cache_audit_changed carries the word now, so both the check and the gate hold it — but the general gap remains, and naming it is more useful than pretending the check closed it.

Why the harm is the worst class this project has had

Once a read-only image page is mapped from the cache instead of copied, one frame is the text of every process running that program. A stray write reaches all of them, and surfaces as an unrelated program misbehaving far from whatever wrote. That is the family that took four attempts and produced three confident wrong answers.

The audit compares against the file, not a checksum taken at fill time — a checksum only proves the bytes have not changed since the kernel last looked.

What was asserted, and the second assertion that matters more

Two, not one:

  • cache_audit_changed must be zero.
  • cache_audit_checked must be non-zero.

The second is the one this project has been taught to add. An audit that examined nothing reports changed=0, which is byte-for-byte the healthy answer; VIBEOS_BLK_TIMEOUT was asserted for months in exactly that state. Both were sabotaged and both went red on metal:

caseresult
force one entry to differreason=invariant_failed:exec_cache_page_changed=1, with a witness naming file, offset, phys, byte index and both values
make the audit return immediatelyreason=invariant_failed:exec_cache_audit_checked_nothing
remove the MUSTBEZERO wordcheck stays green at counters=6, gate goes red with exec_cache_audit_missing

The third is the interesting one. The check does not fail when a counter leaves its view — it just stops counting it. The gate is what covers that here, and that asymmetry is recorded in exec-cache-audit.txt rather than smoothed over.

And a note on writing this file

The case file was first written with ran: yes on all four cases before any of them had been run. They were then run, and two of the four descriptions turned out to be wrong: case 3 does not fail the way it was written, and case 4 proves something narrower than claimed. Both are corrected in place. The sequence is the one this project keeps insisting on and it keeps being necessary: write the sabotage first, run it, then write the sentence it justifies.

Step 2 begins: the keyboard, and a counter that cannot report its own worst case

The sweep before this one is worth recording because it came out negative. 254 name=0x fields are printed under kernel/; 100 of them are not read by the gate. That looks like a hundred defects and is not. Most are witness fields - rax, pml4, fault_addr, freed_by, oor_lba - which exist to say what when a count says how often, and asserting them is meaningless. Several others carry comments stating deliberately that they are not must-be-zero: rmap_untracked (a frame outside its region is legitimately unmapped), rmap_audit_torn (a fact about concurrency, not a defect).

Building a check on that list would have repeated the mistake the lock property made an hour earlier, at three times the scale. A check whose first run reports a hundred things is reporting its own design.

One idea from that sweep died on inspection, which is the better outcome than dying after being committed. The gate detects a TLB shootdown timeout by matching a log string, not the counter, and log_dropped is printed and unread — so "a dropped log line can hide a real timeout" looked like a strong finding. It is false: hw_log_emit writes straight to the serial port and the portable ring is a separate copy, so a drop there hides nothing from the gate. And a ring that overwrites when full is behaving correctly, so asserting log_dropped == 0 would have been wrong twice over.

The keyboard, which had no counter of any kind

Two bare returns dropped keystrokes: the IRQ path when the ring is full, and vibeos_x86_64_keyboard_inject when the boot self-test's script does not fit.

They are different harms and are counted differently.

kbd_dropped — reported, not must-be-zero. A full ring under a fast typist is how a ring behaves. It is printed because nobody types on a CI boot, so a non-zero value there means something is injecting into the console.

kbd_inject_truncated — must-be-zero, and the reason is already in the source. The comment above KBD_RING records this happening: the ring was 512, the self-test script outgrew it, and the symptom was the last command silently never running, which looked like the shell hanging rather than like input being dropped. The ring was enlarged. Nothing was added that would say so next time.

A truncated injection means the self-test checks a prefix of what it meant to check and passes — the seventh instance of this project's "right about the outcome, wrong about the mechanism", arriving through the one path where the input is the test fixture.

And the honest limit, which is the part worth keeping

The plan says: demonstrate the counter can be non-zero before concluding anything from a zero. Two demonstrations were run and they establish different things.

Forcing the accessor to return a non-zero value took the boot to reason=invariant_failed:kbd_inject_truncated=3, with the value 3 carried intact through accessor, report line, regex, assertion and reason. That proves the plumbing.

Shrinking the ring to 4 — the real harm at full strength — did not produce a non-zero counter. The boot failed earlier with reason=missing:CLI_READY, and none of the late report lines printed at all. With no input the machine never reaches the point where it reports.

So: this counter is printed by a path that a working console is a precondition for. That bounds what it can catch, precisely:

  • a partial truncation, where enough script survives to reach the report — the case that once looked like a hanging shell, and the case the counter exists for — is caught.
  • a total loss of input is caught by CLI_READY instead, loudly.
  • the gap between them is untested, because the script length and ring size are both fixed and every ring small enough to truncate is small enough to break the boot first.

That last line is recorded as untested rather than counted as covered. It is the same distinction the AHCI cases already carry between "no case exists" and "the case exists and this environment cannot tell".

The keyboard counters made the plan's headline metric worse, and the check said so

check-blast-radius.py, written the same day, went red on the keyboard commit.

input device: 2 -> 4. Giving the keyboard its first must-be-zero counter cost two files that have nothing to do with keyboards: an accessor declared in arch_x86_64.h, and a print in kmain.c. Total across six points: 21 -> 23.

That is a structural regression caused by an observability change, caught within hours by a check written to measure exactly that, in the one direction nobody would have looked — the change made things better by C2's measure and worse by C1's.

The finding underneath it

This is not a keyboard problem, and it does not go away by being careful.

Observability itself has a blast radius here, and it is 2 per module.

There is no seam a module registers its statistics into. Every counter C2 adds — and there are roughly thirty modules left — pays the same two files, so C2 as planned would raise the total by about sixty and hand C6 a worse tree than it found.

The number is raised to 4 with that argument recorded in the check, which is what the plan asks for: a radius going up needs an argument, not an edit. A stats registration seam takes this row back to 2 and takes the next thirty counters with it, and that now has a measured justification rather than being a matter of taste.

And a defect in all four checks of this family

The failure arrived as VERDICT=RED with no reason beside it: the blast-radius= line was missing from the summary entirely.

check.sh reads each check with | tail -1, and all four of these checks printed their advice line after the verdict. So on failure the summary got the advice and the verdict vanished — the line that matters printed and not read, for the fourth time today, this time in the tooling built to stop that.

check-chokepoints.py had the same order from the day it was written and had never shown it, because it has never gone red inside check.sh. Fixed in all four: advice first, verdict last, and confirmed by breaking a baseline and watching tail -1 produce subsystem=FAIL.

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.h declares 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 named op, not syscall: 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.c is the Linux ABI as a translator: a constant number -> operation table plus clone()'s decision by flags (which is why classify takes the first argument). The ABI is bound to a task in hw_task_alloc, the one place a slot is claimed, and read from the task.
  • scripts/dev/check-syscall-checks.py holds 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, prctl touch user memory).
  • Found and removed on the way: vibeos_linux_translate_syscall was 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/:

filewhat
dispatch.cclassify, then switch on the operation
fs.copen, read, write, pipes, stat, getdents, ioctl, the console
proc.cfork, clone, execve, wait, exit_group, prctl, futex, prlimit
mm.cbrk, mmap, mprotect, munmap, pageinfo
sig.c, signal.ckill/tkill/tgkill/sigaction/sigprocmask; frame building and sigreturn
net.cthe socket calls (was arch/x86_64/linux_socket.c)
misc.cuname, clock_gettime, time, sysinfo (added for the reclaim load)
common.c, linux_internal.hwhat 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.c named 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.h to arch_hw_internal.h is 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 and include/vibeos/ksvc.h.)

Stage 2b: the pointer arguments are declared in the row (2026-09-21)

  • A row's fourth column is NOPTR or PTRS(...): 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 in linux_internal.h.
  • The dispatcher's check_pointers validates 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_ok has one call site (linux_user_ok, dispatch.c); it was 46. check-chokepoints.py reports 4 for it (definition, two declarations, that call) and watches linux_user_ok at 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_pointers and 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, and cases/core-ptr-engine.txt shows 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.c looks the number up in the registry; the central switch is 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.txt on purpose, and check-syscall-checks.py holds 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 in abi-abi_linux.txt; all red. Full check green, 3/3 boots, clang and gcc warnings 0.

C5 - pipes as a module

Status, 2026-09-21: done. This is the detector core_c5_task_identity.md said the descriptor work was waiting for.

What it was

hw_pipe_t, g_pipes[] and g_pipe_lock were globals of the architecture layer, touched from twelve places in the Linux ABI: read, write, pipe creation and its two rollbacks, dup2 (twice), fork, clone, close and exit each adjusted the readers and writers counts or the ring by hand. That is the classic hung-pipeline defect - a count that is wrong on one path - with no place to check it.

What it is now

include/vibeos/pipe.h, kernel/ipc/pipe.c: portable, no globals outside the module.

  • One attempt, then the caller decides. vibeos_pipe_read / _write move what they can and return a status - OK, EMPTY, EOF, FULL, NO_READER, FAULT - and never sleep, raise a signal or touch user memory. The waiting, SIGPIPE and the wake-ups stay in fs.c, which knows about the scheduler. The copy to and from user memory is a function the caller passes (vibeos_uaccess_copy has the shape).
  • Serialised by its own lock, registered by the architecture (vibeos_pipe_set_lock; a registration, not a weak symbol - that does not resolve on the Windows build). The fourth layer in this project that needed one, and this time it was written in from the start.
  • Ends are acquired and released through the module: vibeos_pipe_end_acquire and _end_release (which detaches the descriptor, so it cannot release twice). dup2, fork/clone and close use them; the three copies of "increment the right count under the lock" are gone.
  • Two must-be-zero counters, gated. pipe_end_underflow: an end released with none held - the old code clamped it at zero and carried on, which is exactly how a wrong count stays invisible until a pipeline hangs. pipe_bad_slot: a descriptor named a pipe that is free or does not exist. The boot gate already asserts the registry total is zero, so both are gated from the first boot; the host test drives each on purpose (test_mbz_all_demonstrated requires that).

What the first boots said

All three boots read zero for both counters: no double release and no dangling slot anywhere in the boot script, including the pipelines, fork and the threads test. That is a measurement, not an assumption - the clamp used to make it unknowable.

Gates

Host test pipe_tests.c (about 50 checks: the ring across its wrap, EOF versus EMPTY, a drain after the last writer closes, a fault that consumes nothing, a duplicated end needing a second release, the module lock taken and never nested), and cases/ipc-pipe.txt: end of file without asking about writers, a pipe freed when either end goes, an underflow clamped instead of counted, a write accepted with no reader - each red on a named check. ipc/pipe is the first module of this phase with a real must-be-zero, so mustbezero_exempt stays at 23 and is not raised.

Not done

hw_fds_inherit now acquires each pipe end separately, where the old code held the pipe lock across all of them; another core cannot observe a half-inherited table because the child is not runnable yet, but the property is argued, not tested.

C5, first step: threads are one process

C5 in docs/core/phases.md was "one owner for what a task is": move the task table, keep only what a context switch needs in the arch layer. It was planned after C4. It came first, and it came in a different shape, because five external findings in two days turned out to be one defect in the structure C5 exists to fix - and moving a structure with the wrong ownership model only relocates the defect.

The defect

hw_sys_clone_thread created a thread with

child->proc = parent->proc;

which copies hw_proc_t by value. Everything in it that belongs to a process became a private copy per thread. The address space survived that, because what was copied was a pointer to the page tables and exit counts who still holds them. Nothing else did:

reported aswhat the copy did
mapping cursorfound while verifying the othersa thread's mmap never moved main's cursor, so main was handed the same base and its fresh pages silently replaced the thread's
signal dispositionsM-003sigaction in one thread was never seen by its siblings
exit_groupH-005shared a case with exit and ended only the calling thread
region listH-004two list heads into one pool; a node one thread freed was still reachable from another's head, with its next overwritten by the free list
descriptorsalready admitted in a commentcopied, not shared - fixed later, M-072 (core_c5_residue.md)

The sigaction row is the one worth remembering. The copying loop in clone_thread sat directly under the comment "Signal dispositions are the process's, so a thread inherits them." The sentence described the intent and the loop beneath it contradicted it.

The mapping cursor was not in any review. It came from asking, for each field in hw_proc_t, "who writes this, and do the writers agree on which copy?" - the answer for mmap_cur was two syscalls writing whichever copy the calling thread happened to own.

Why the existing tests were green

musl_threads.c created every thread from main, and no worker touched process state. A kernel that gives each thread a private copy of the process passes that test exactly, because each child copies an up-to-date value at the moment of its creation and never changes it.

Tests first, run against the unchanged kernel

Three stages were added to tests/linux/musl_threads.c and asserted by the boot gate before any kernel code changed. Each was run against the old kernel and failed for the reason it was written for:

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

On the same boot the three older stages stayed OK, which is the point.

Two things were checked before the stages were trusted to be able to fail at all. SIGUSR1's default action here is to kill - hw_signal_default_kills - and a default of "ignore" would have made the sigaction stage pass on the defect. And the exit_group stage requires the code 42, not merely that the process ended: waitpid builds the status from the leader's exit_code and exit_signal, so an exit_group implemented by SIGKILLing the siblings would report "killed by signal 9" for the leader. Requiring 42 means that implementation cannot pass.

The boot that established the red was guarded against the traps this project already knows: the gate compiled, the build returned 0, and both the test binary and the serial log were newer than the source. A stale THREADS.ELF prints no C5 lines at all and would have read as three did_not_report failures that proved nothing.

The change

hw_procstate_t, referenced rather than copied. It holds the program break, the mapping cursor, the region list, the four disposition arrays and the exit_group state. hw_task_t points at one. fork and exec create one, a thread takes a reference, exit gives it back. The pool is sized for every task plus one exec in flight per core, because exec builds the new process before it lets go of the old one.

The last reference empties the region list before the slot is published free, so a concurrent allocation never inherits a list that is still being cleared. That ordering is safe because the only thing that raises a count is clone, which runs on a live thread of the same process - and the holder of the last reference is the thread that is exiting.

The mapping cursor is claimed with a compare-exchange. Sharing it turned a deterministic defect into a race: two threads reading and advancing one cursor at once can still get the same base. A lock around the mapping would have held thousands of pages with the timer off, so the range is claimed first and mapped afterwards, and nobody holds a lock across the mapping. A range whose mapping fails stays a hole in address space.

brk runs one at a time per process. A thread shrinking the break while another grows it would unmap pages the other has just mapped. Claimed with a compare-exchange on a busy flag rather than a spinlock, for the same reason as above. A C library already serialises its own brk calls, so it is almost never contended; it has to be correct, not fast.

exit_group records the group's code, then SIGKILLs every sibling - which also wakes one that is blocked - and exits. Delivery of that SIGKILL consults the recorded code, so the leader reports 42 rather than "killed by 9". The first caller's code wins, as on Linux. It does not hold g_sched_lock while raising, because hw_signal_raise can take it through hw_task_set_state - the same reason kill() already iterates without it.

exec gets a new process. Its dispositions are derived from the outgoing one - caught signals revert to default, ignored stays ignored, masks carry over - and they are derived before the reference to the outgoing process is given back. Once the last one goes, the slot can be handed to an exec or a fork on another core, and reading it afterwards could copy a stranger's dispositions.

What was deliberately not done

  • exec in a multi-threaded process does not end the siblings. Linux does; here the exec'ing task moves to a new process and its siblings keep running the old image on the old one. That is exactly what happened before this change, so it is no worse - and exec is the path that once produced the cr2 == rip defect, so it was changed as little as possible. It is the next step, and it can use hw_task_exit_group for the siblings.

  • A leader that exits while its threads run becomes a zombie the parent can reap early. Linux waits for the whole group. Not changed, not tested.

  • Descriptors stay per thread. The source already said so, and it is the next row of the same table. Closed 2026-09-27 (M-072).

  • fork is not atomic against its own process's other threads. This was first written down as "fork reads the break without holding it - three lines", and the estimate was wrong in scope, found while writing those three lines. fork copies the pages (hw_aspace_copy_user) well before it clones the regions and copies the break, and no address-space lock covers that stretch - so a sibling running mmap, munmap, mprotect or brk during a fork can leave the child with pages and regions that disagree, not just a stale break. A guard on brk alone would be a partial fix presented as a whole one. Linux holds one address-space lock across fork and all of those calls; that is the shape of the fix, and it is larger than a line.

  • exit_group cannot end a sibling that is blocked in the kernel. Found while preparing the next step, after this change was already green. SIGKILL is delivered on the way back to ring 3, and no blocking wait in this kernel reads sig_pending: hw_futex_wait loops on while (!woken) hlt, and the pipe and waitpid waits have the same shape. hw_signal_raise moves a blocked task to READY, the scheduler runs it, and it goes straight back into the loop. So exit_group ends a sibling running in user space, and one blocked in pthread_join on the exiting thread - that one is woken by the exiting thread's own clear_child_tid - but not one waiting on an unrelated condition variable, a pipe or a child. The process then never ends. That is not a regression - the sibling stayed blocked before as well - and it is not about threads: kill -9 cannot end a process blocked on a pipe today either. It is the prerequisite for finishing exit_group and for exec ending its siblings, so it goes first.

    There are four such waits, not three. Console read() sets wait_input, and hw_signal_raise clears it - but the only code that reads the flag is the keyboard's wakeup, and the read loop itself just tries the keyboard again and re-blocks. scripts/dev/cases/ctrl-c.txt already records why nobody saw this: the Ctrl-C delivery case was written, run, and came back not red, because by the time the kernel CLI runs there is no foreground process left to reach. Delivery of a signal to a process blocked in the kernel has never been observed by any test here.

    And the test that would observe it cannot be written with the obvious bound: case LSYS_wait4 calls hw_sys_waitpid(a1, a2) and the options argument never arrives, so WNOHANG is ignored and a "bounded" poll blocks exactly like an unbounded wait. A shell reaping background jobs depends on that option too, so it is a defect in its own right, and it goes first of all.

    Closed, with tkill, in the next commit. WNOHANG is honoured; WUNTRACED, WCONTINUED and Linux's __W bits are accepted and have no effect - refusing them would break BusyBox's shell - and unknown bits are refused. hw_task_by_tid had been return hw_task_by_pid(tid); under a comment saying a thread id and a process id were the same number, which stopped being true when clone(CLONE_THREAD) gave threads their own ids: a non-leader's id matched nothing and tkill answered ESRCH, and raise() - which a C library implements as tkill(gettid()) - failed in every thread but the first. Both had red stages first (waited for the child to exit, pthread_kill on a live thread returned 3); both green on the first build; twelve boots: 11 pass, 1 fail.

    And the four waits, closed after that. Every blocking wait asks hw_signal_interrupts now. Three decisions carry it, each forced by something read rather than assumed. hw_task_set_state takes no lock and hw_signal_raise wakes a task without g_sched_lock, so each wait sets BLOCKED first and asks second - the other order has an interleaving in which the signal lands between the question and the block, raise sees a running task and wakes nothing, and the task parks with the signal pending. futex_wait decides "woken or interrupted" under g_futex_lock, or a concurrent FUTEX_WAKE would be counted as delivered to a waiter that returned EINTR and the thread it was meant for would never be told. And a signal that would be discarded - ignored, or a default that ignores - interrupts nothing, because the native shell ignores wait4's result and a spurious EINTR would stop it waiting for its child; SIGCHLD is never raised today, which is exactly why that cannot be left to chance. Red first (THREADS_C5_EXIT_GROUP_BLOCKED_FAIL: the process never ended), green after; twelve boots: 11 pass, 1 fail.

    A futex word has an owner too. Found while reading hw_futex_wake to explain a crash it turned out not to explain: the waiter table was keyed by user virtual address alone, and a virtual address means nothing without its process - every Linux program here links at 0x400000, and a forked child has its parent's layout exactly. A wake in one process could end a wait in another. Not claimed as the cause of the stack-poison crash in the four-worker stage: musl re-checks its condition after every wakeup, so a spurious one alone does not return pthread_join early. Waiters now record the hw_procstate_t they belong to, and a wake matches the process and the address; there are no shared mappings in this kernel, so the process is the whole key. The exit path's wake of clear_child_tid passes the dying thread's process, which is still attached there because that block runs before hw_procstate_put - an ordering the comment beside it now defends, because the other order would hang every pthread_join with nothing failing to compile. Red first (THREADS_C5_FUTEX_XPROC_FAIL: a wake in this process woke 1 waiter(s) in another), green after; 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.

    An exec from a thread ends the process's other threads, and the caller becomes the process (H-006, verified). Neither happened: the siblings kept running the old image, and a thread that was not the leader kept its own id and is_thread = 1, so the program it loaded ran as a thread - released at exit instead of left as a zombie, its exit code unreachable by the parent. Now, only once the new image has loaded (a failed exec must leave every thread in place): the leader is marked a thread under g_sched_lock, so its exit is decided quietly; every sibling, found by thread-group id rather than by process reference because an already-exited leader has given its reference back, gets SIGKILL; exec sleeps until they are gone, releasing a leader that was already a zombie; then takes pid = tgid and clears is_thread. If a signal must be acted on during that wait, the uncommitted image is destroyed and exec returns EINTR. The wait is unbounded on purpose: waits notice signals now, so a hang there names a wait that ignored one. Exec also clears clear_child_tid - the address belongs to the image that set it, and was left for the new image's exit to zero, on every exec. Red first (THREADS_C5_EXEC_FAIL: exited 7, expected 23 - the leader outlived the exec), green after; twelve boots: 12 pass, 0 fail.

    The shared process block cannot be given back twice. hw_procstate_t is refcounted - threads share it, fork and exec make new ones, and exit gives one back - so a reference returned that nobody held is the process-state analogue of a double free of a frame. hw_procstate_put counts that case (procstate_double_put) on the [TASKS] MUSTBEZERO line, and the boot gate fails if it is not zero. It closes the C5 step-0 item that asked for a must-be-zero here. Proved able to fire: with a deliberate second put of every dying process's state, the gate reported reason=invariant_failed:task_procstate_double_put=119 and went red; reverted, the restored tree is green.

    A slot index is not a reference (H-007, verified). The syscalls that name a task by id - kill, tkill, tgkill, setpgid, getsid, setsid - resolved it to a g_tasks index and then used the index without a lock, and so did the sibling loops of exit_group and exec. A slot is reused as soon as its task is reaped, so between the two a fork could take it: kill(old_pid, SIGKILL) would land on the new tenant, whose permission check was the one made. pids are never reused, so the index is safe exactly while g_sched_lock is held - every allocation and every publish to FREE (thread exit, waitpid's reap, exec releasing a zombie leader) happens under it - and lookup, check and act are now one critical section. exit_group's loop matched on ps, which does not help: the hw_procstate_t pool is reused too.

    A second half turned up while reading it: allocation clears the address space, descriptors and thread flags but leaves is_user, pid, tgid, sid and pgid until fork or clone rewrites them, so a slot still being built answered to a dead process's id. Lookups skip RESERVED now.

    Two comments said hw_signal_raise could not run under g_sched_lock because it reaches hw_task_set_state. That function takes no lock, and hw_schedule already calls it under this one; one of the two comments was written for H-006 earlier the same day and repeated the other without checking.

    No red test, and that is a statement rather than an omission: the window is a few instructions between two cores, and a stage that cannot fail would prove nothing. 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.

  • No must-be-zero counter for the new structure. One was designed - a reference dropped below zero - and not added, because a counter nothing reads is this project's most repeated defect, and reading it means touching the portable stats struct, its printer and the gate in the same change. A leaked process would show today through vmas_live, which the gate already asserts returns to zero. Recorded as debt, not as coverage.

Found on the way

Rewriting hw_sys_mmap for the claimed cursor exposed that this morning's fix for the review's M-002 had covered one of two allocation loops - the reservation branch - and missed the ordinary anonymous path that malloc reaches. The note in that commit saying the leftover pages were unreachable from ring 3 was true only of the loop it fixed. Both loops unwind now, and mm_brk_mmap_leaks.md carries the correction.

Verification

first build after the changeall three C5 stages OK, reason=cli_and_network_verified
check.sh all build-clang-ReleaseVERDICT=green - rc 0, warnings 0, clang 0/0, host tests pass, every check ok, 3/3 boots
twelve-boot series, evidence kept outside /tmp12 pass, 0 fail; TLB quarantine overflow 0 on every boot

The three older THREADS stages stayed OK throughout, as they did on the red run - they were never able to see this.

C5 residue: mprotect against fork, and one descriptor table per process

C5 closed with two items written down as out of its scope (phases.md, "Still open"). Both are closed here (2026-09-27), together with a third defect found while proving the second.

mprotect held the address-space lock for half the call (M-071)

hw_sys_mprotect took hw_mm_lock around the region-list update and released it before narrowing the page tables. The reason was real when it was written: narrowing does a TLB shootdown, and a sibling spinning in hw_mm_lock with interrupts off could not acknowledge the IPI, so the shootdown timed out and panicked. So a fork on another thread - which walks the page tables under the same lock - could copy an entry mprotect was halfway through changing.

The reason has gone. The lock's spin opens an interrupt window every turn, and since M-065 a waiting core answers flush requests itself. mprotect now holds the lock from the range check to the last entry, with a single exit.

Reading the function for that found the other half. The range check and the page-table pass both asked hw_pte_lookup, which answers only for a present entry - so mprotect on a page in swap was refused with EFAULT, although a swapped entry keeps its permissions (M-063) and vibeos_vmspace_protect already knew how to change them. The check now accepts present or swapped, and the pass hands every page to vibeos_vmspace_protect.

Proof. svc-reclaim now makes every block read-only before reading it back; by then most of the oldest are on disk. With the check put back to "present only" (scripts/dev/cases/mm-mprotect.txt) the boot goes red with reclaim_load_failed - RECLAIM_FAIL: mprotect refused block 0 - for the right reason, and green without the sabotage. The lock itself has no case: a fork has to read an entry in the few instructions between the list update and the narrowing, which one boot does not arrange. Recorded as untested.

The ABI self-test's case that mprotect refuses an unmapped page (linux-abi.txt) had its anchor re-pointed at the new check, found by check-sabotage-anchors.py on the first check.sh all.

Descriptors were copied per thread (M-072)

clone(CLONE_VM|CLONE_THREAD) gave the new thread a copy of its creator's table, and the source said so in a comment. So a descriptor one thread opened did not exist in the others, and a close in one left the rest holding it - the last of the five process-state copies core_c5_process_state.md lists.

The table moved into hw_procstate_t, beside the dispositions and the region list, and a thread shares it by holding the same reference. fork and exec take a copy (hw_fds_copy, every pipe end in it gaining an owner); a thread takes nothing.

Sharing the table made two things true that were not:

  • Two threads can open at once. open looked for a free slot, then read the disk, then marked the slot used - a sibling opening meanwhile found the same slot free. Claiming now goes through files_lock and happens before the lookup; a failed lookup gives the slot back. pipe claims its two ends the same way instead of scanning by hand. close takes the entry out of the table in one step under the lock and finishes with its own copy, so a second close of the same descriptor from another thread gets EBADF instead of releasing a pipe end twice. dup2 reads, replaces and re-references in one critical section. The lock is not held across I/O: two threads reading one descriptor at the same moment share its position without ordering, a difference from Linux recorded in the structure's comment.
  • Who closes the table. Every exit closed its own copy. Now the table goes with the last thread to leave it, counted by files_users - apart from the process reference, because exit gives that back only after it has switched away, and closing a pipe has to happen before. The same change fixed a defect nobody had reported: each thread's exit released all of the process's sockets by thread-group id, so a worker that finished closed the connections its process was still using. They now go with the last thread too.

Proof. THREADS_C5_FILES in tests/linux/musl_threads.c: a thread makes a pipe and main reads from it; then main closes the write end while another thread waits to write through it, and that write must fail. The gate asserts it (thread_descriptors_not_shared, threads_c5_files_did_not_report). Run against the kernel before this change - the test first, the fix stashed - it printed THREADS_C5_FILES_FAIL: a pipe a thread opened is not open in main. Sabotage (scripts/dev/cases/fd-shared.txt): a clone that does not count itself as a user of the table, and an exec that does not copy it. The lock has no case, for the same reason as mprotect's.

The first of those went NOT RED at first, and the reason is worth more than the case. With the increment gone, the first thread the threads test ever creates takes the count to zero on exit - closing a table that held nothing of interest yet - and every exit after that wrapped it below zero, so by the time THREADS_C5_FILES ran nothing closed anything and the test passed. The symptom appears once, wherever the first exit happens to fall; the underflow happens on every exit after it. So the leave refuses to go below zero and counts the attempt as files_double_leave, on the [TASKS] MUSTBEZERO line the gate already asserts - the mechanism gated, not one lucky symptom.

check-task-identity.py asserted the table was embedded in hw_task_t; it now asserts the opposite and that hw_procstate_t holds it, and its ratchet on lines that index the table directly went from 17 to 12.

rmap_missing_remove was an eviction finishing inside a teardown (M-073)

Running the new threads test against the old kernel was supposed to show one failure. It showed three, and one of them was mm_rmap_missing_remove=1 - the rare counter that had fired once in about fifty boots and had been instrumented since. The instrumentation named it at once: RMAP_MISSING with a caller in vibeos_vmspace_swap_out, and a virtual address inside svc-reclaim's blocks.

That boot's svc-reclaim had exited early - the old mprotect refused its first block - with some 1,400 blocks still mapped, so its teardown ran while the clock was evicting from it. vibeos_vmspace_destroy calls vibeos_rmap_forget_root, which removes every holder recorded for the root first and only then waits for reclaim's claims on it (M-056). A reclaimer that claimed a page before the teardown finishes its swap-out afterwards: the entry is still there, the commit succeeds, and its vibeos_rmap_remove finds nothing, because the teardown took it. Nothing is wrong - the frame is released once, by the eviction, and the teardown frees the slot the entry now names - but the must-be-zero counter said otherwise.

forget_root now marks the claims it waits on, and a remove that finds nothing for a root with a marked claim outstanding is counted as removed_after_forget, which is printed and is not a must-be-zero. A remove that finds nothing anywhere else is still missing_remove. The host test test_eviction_during_teardown plays the reclaimer from the wait's relax hook, and the sabotage that stops marking (mm-rmap-claims.txt) turns it red. Clearing the mark when the last claim goes was tried as a case too and went NOT RED: the mark is read only while a claim is outstanding and a new claim clears it, so the clearing was redundant and was removed rather than tuned.

Lesson worth keeping from the order of events: the rare defect was not found by hunting for it. It appeared when a load that normally runs to completion stopped early and left a large teardown to race the clock - an arrangement no test had set up on purpose.

The arrangement, made on purpose

That arrangement is also where the two defects still open were seen: M-070 (a page lost and a slot freed twice) in a boot where svc-press exited beside the load, and M-068 in the boot where the load's first version outlived init. Twenty-four boots with the clock's second chance disabled - the thrash in which M-070 first appeared, rebuilt in a separate worktree - were all clean, and all had rmap_claim_waits=0: not one teardown ever met an eviction in progress. Thrash alone does not produce it.

So svc-reclaim has a second half. After RECLAIM_OK, a child fills memory past the low mark as the main load did, tells the parent through a pipe, and exits with everything still mapped; the parent allocates at once, below the low mark, so every page it takes goes through reclaim, and the clock lands mostly on the child's frames. The first version had the child exit immediately and the race still did not happen: every page it owned had just been written, so the clock's first pass only cleared accessed bits, and the teardown was over before it came round again. The child now waits until swap has taken 64 more pages - until reclaim is evicting - before it exits. The first boot of that version reported rmap_claim_waits=1 rmap_removed_after_forget=1: exactly the event that had been rmap_missing_remove=1, now arranged and classified. The gate asserts RECLAIM_ABANDON_OK (reclaim_abandon_did_not_finish); what the race must not produce - a slot freed twice or left allocated, a frame lost - is caught by counters it already asserts.

C5 - what a task is, apart from what a CPU needs to run it

Status, 2026-09-21: steps 1 (identity) and 2 (descriptors) done; the phase is not finished. Step 0 (process state referenced, not copied) is core_c5_process_state.md.

What moved

hw_task_t was one structure holding everything about a task: the saved registers, the kernel stack, the address space and descriptors and who it is. Seventeen fields of identity are now vibeos_task_t (include/vibeos/task_ident.h, code in kernel/sched/task_ident.c), embedded in hw_task_t as id:

pid tgid ppid pgid sid service_id is_thread is_user is_idle wait_input signal_stopped exit_code exit_signal clear_child_tid comm sig_pending sig_blocked

The portable type has host tests and functions, and the arch layer uses them:

  • vibeos_task_identity_reset - the one place identity is cleared, called from hw_task_alloc. Before it, alloc reset three of these fields (is_thread, service_id, clear_child_tid) and a recycled slot kept the previous tenant's pending signals, exit status, pgid and name until each creator overwrote them - the shape of interp_base again. It clears the whole object byte by byte, so a field added later is cleared without anyone remembering to say so. The host test fills the structure with 0xA5 and needs every byte back to zero; its first version caught the reset missing padding.
  • vibeos_task_accountable_to - the fork guard's "does this parent answer for this task" (child, or a thread of its group), replacing an inline condition.
  • vibeos_task_set_comm - bounded and always terminated, replacing a loop in prctl that left the tail of the previous name.
  • vibeos_task_is_group_leader.

How the ~180 call sites were moved

Not by search-and-replace: pid and ppid also name fields of other structures. The fields were removed from hw_task_t and the compiler then named every access - file, line and column - which a small tool rewrote in place (.pid to .id.pid), round after round until the build had no such error. Only accesses to hw_task_t can fail that way, so nothing else was touched.

Step 2: the descriptors

hw_fd_t was already plain data (a pipe or socket is an index, not a pointer), so it moved as it was: vibeos_fd_t and vibeos_fdtable_t (include/vibeos/fdtable.h, kernel/fs/fdtable.c), embedded in hw_task_t as files; hw_fd_t remains as an alias so the ~60 places that name it are not renamed. The table states the ABI's split once - fds[] is descriptor 3 onwards, std[] is what 0, 1 and 2 have been redirected to - and provides reset, get, redirect, free_index, claim, copy and an index walk. What it replaced:

  • fork and clone each copied the table by hand and then walked it to give every pipe end an owner - two copies of about twenty lines. It is hw_fds_inherit now, once. A change to how inheritance counts a pipe end used to need making twice, and the case that is missed hangs a pipeline.
  • the reset in hw_task_alloc (two loops) is vibeos_fdtable_reset; exit walks every entry once instead of the table and the redirections separately; hw_fd_alloc, hw_fd_get, open and dup use claim, get and free_index.
  • a latent sign mismatch surfaced when VIBEOS_HW_MAX_FDS became unsigned (gcc -Wsign-compare in pipe); fixed by keeping the constant an int.

Sabotage cases/fs-fdtable.txt: an off-by-one bound, a claim that does not clear, a copy that skips the redirections - each red on a named host test. The first version of the bound test would not have caught the off-by-one (in the real structure the entry past the last is std[0], which is unused, so it answered NULL by luck); the test now marks it used first. That is this project's usual lesson, met again.

What this does not finish

The phase's done-condition is that arch_hw.c names no task field that is not part of a context switch. It is not met: arch_hw.c reaches into identity on 92 lines and the Linux layer on ~126. check-task-identity.py states the two properties that can be stated - hw_task_t may not declare an identity field again, and the 92 may only go down - and is in check.sh. The process pointer (ps), state, on_cpu, the scheduling timestamps and the fork of a whole task are still hw_task_t's. 17 lines still index the table by hand (pipe, dup2 and socket code that arithmetics on fd - 3); check-task-identity.py ratchets them.

Gates: host test (task_ident_tests.c), check-task-identity.py, sabotage cases/core-task-identity.txt (a second pid) and core-task-identity-reset.txt (a reset that clears half) - both red.

Two things found on the way

  • PTR_ in linux_internal.h did not initialise the two fields C4's futex descriptor added, so gcc reported -Wmissing-field-initializers on every row. The futex commit was reported green on a run whose head I had cut off with tail - the trap this project's notes describe. Fixed here; the clean run reads warnings=0.
  • check-subsystem.py's mustbezero_exempt went 21 to 22 with sched/task_ident (pure functions over a caller's struct; no state to be wrong). That baseline "may only go down", so it is recorded as a decision beside the number rather than edited.

Decision: exemptions, not counters (2026-09-21)

mustbezero_exempt rose 21 to 23 (sched/task_ident, fs/fdtable). A real must-be-zero was weighed and declined: both modules are pure functions over a struct the caller owns, so a counter inside them could only report on its own arguments, and the defects that live here (a field the reset forgot, an off-by-one bound, a copy that skips the redirections) are what the host tests and the fill-with-a-pattern check catch deterministically. A counter earns its place where state is shared and mutated at runtime. The one such place this phase touches is the pipe-end count that hw_fds_inherit, close and exit all adjust; the pipe table is not a module yet, and its detector (a writers/readers count that goes negative or disagrees with the descriptors that name it) belongs with it when it becomes one. Done: see core_c5_pipes.md - the pipe table is a module now with two real counters. The two exemptions stay, since they were about pure functions, and the baseline is not raised again.

C5 - the task's state has one owner

Status, 2026-09-21: done.

What it was

The task layer (kernel/sched/task.c) already owned the transition table - the one place that says which state changes are legal - and hw_task_t kept a second copy of the state, written after each transition. Two opinions about what READY means is how two cores once ran one task, and the copy was also read from 52 places, so every reader trusted whichever the code near it happened to use.

What it is now

  • hw_task_t has no state. hw_slot_state(slot) / hw_task_state(task) ask the task layer, and the only writer is hw_task_set_state, which calls the transition. The 52 readers were rewritten from the compiler's own error positions (g_tasks[i].state became hw_slot_state(i), t->state became hw_task_state(t)), not by search and replace. The state array is volatile inside the task layer: other cores read it while a transition writes it, which the old field's volatile said and the new one must.
  • ready_at moved with it. The tick a task became runnable was stamped by hw_task_set_state when the target was READY, on the argument that this is the only writer so no path can forget. That argument belongs to the transition, so it is stamped there now (vibeos_task_ready_at), and a new tenancy starts at zero.
  • ran_once became vibeos_task_first_run: true once per tenancy, atomically, and reset when a slot is claimed. It was a flag the arch layer set by hand.
  • on_cpu stays where it is: it is a fact about a CPU executing the task and only the context switch reads it.

Gates

Host test (the stamp on READY and not on RUNNING, restamped on each return to READY, a new tenancy starting unrun with no ready time, an out-of-range slot answering "no"); cases/sched-task-state.txt (three sabotages, each red on a named check) and a fourth core-task-identity.txt case (a second state in hw_task_t). The five existing sched-task.txt cases still go red.

Measured, because the state read became a function call

The syscall and scheduling paths now call the task layer to read a state. syscall_min under TCG, three boots each: c6efd0a (before this session's work) 887-980, ebe9fae 793-934, f1e2b76 489-560, this tree 776-949. Nothing regressed, and that is all it shows: the spread between builds of the same logic on the same host is wider than any effect here, so the number cannot resolve a state read. The 229 quoted earlier in this work came from another machine state and is not comparable. The ceiling (5000) holds with a wide margin.

Not done, as of the previous entry

arch_hw.c still names identity on 92 lines, and the fork of a whole task (allocation, the child's registers and stack) is still the arch's - it needs the context and the kernel stack, which are what the arch layer is for. The phase's done-condition is about fields, and what remains in hw_task_t is now: the register frame, the address space, the kernel stack, on_cpu, the FPU area, fs_base, the process pointer, the ABI pointer and the diagnostic markers. Those are the context switch's.

Closing the phase: the lifecycle leaves arch_hw.c (2026-09-22)

The 92 lines were not fields left in hw_task_t - they were code, spread through arch_hw.c, that names identity while doing something that is not switching a context: creating a task, describing it for a diagnostic, ending it, delivering a signal, killing a faulted one. That code moved, whole, to a new file, kernel/arch/x86_64/task_life.c - the same tool C4 stage 3 used (parse into top-level items, build the reference graph, move a function iff everything that references it moves too), pointed at fourteen roots (hw_task_exit, hw_task_spawn_user, hw_task_create_idle, hw_signal_raise, the console interrupt, the ring-3 fault killer, ...) instead of at the syscall handlers. 37 items, 1,605 lines.

What stayed, and the line that says so is now a check. Five functions are the context switch - vibeos_x86_64_isr_handler, hw_task_runnable, hw_task_load_cpu_state, hw_ctx_check, hw_schedule - and only they may still name .id.. check-task-identity.py reads arch_hw.c function by function: identity named anywhere else is a failure by itself, not just a count; the 12 lines those five carry are the new ratchet, down from 92. cases/core-task-identity-arch.txt puts one back in the slot allocator and confirms it is caught, named.

Twelve of the remaining lines were file-scope reads/writes of pid, pgid, is_user and service_id from code that stayed (the diagnostics, the fault path, the console handoff at boot) - not moved, because moving them would have meant moving their callers too, which are switch-adjacent. They go through four small accessors instead (hw_task_pid_of, hw_task_pgid_of, hw_task_is_user_of, hw_task_set_service, defined with the lifecycle in task_life.c), the same shape as hw_slot_state / hw_task_state for the state field itself.

hw_fd_alloc moved too - it is part of a task's lifecycle (claiming its first descriptor), not part of switching to it.

Two checks were found broken while working nearby it, both fixed, both given a case:

  • check-chokepoints.py's count for hw_task_alloc_guarded needed one more site: crossing into task_life.c cost it its static, so it gained a header declaration - the fourth of what was three. Recorded as the decision it is.
  • check-exec-layering.sh (X-P4, kernel/exec/) named arch_hw.c by path and would have failed silently the moment the interpreter substitution moved with the lifecycle. Repointing it at task_life.c surfaced a second, older bug: it flagged a comment two thousand lines away that quotes a boot-log line containing LDMUSL.SO as an example - confirmed already red at HEAD, before any of today's work, by running the unmodified script against a clean worktree. Nobody had run it in a context where its exit code mattered, because it also always exit 0, so check.sh (which reads its printed text) never noticed and nothing else ever checked its return code. Both are fixed - comment lines are skipped, and it exits 1 on failure - and cases/core-exec-layering.txt is its first sabotage case, proving both a real second path is still caught and the log-example comment is not.

Done

C5 is closed: one definition of a task's identity, one table of its state, one module for its descriptors, one module for pipes, and arch_hw.c naming a task's fields only where it switches between them. arch_hw.c is 6,220 lines (was 12,174 before C4); task_life.c is 1,643.

Full check green (clang and gcc, 3/3 boots, host tests, all sabotage cases including the two written today). syscall_min not re-measured after this move - it touches no hot path (creation, exit, signal delivery, diagnostics), and C0's ratchet would have caught a regression in what it does touch.

C6 - diagnostics become a module

Status, 2026-09-22: done. The kernel log and the crash records live in kernel/diag; the UART and the disk are registered sinks; the arch layer keeps the mechanisms that are different on another machine.

The plan (docs/core/phases.md) called this the one move that can invalidate its own evidence: these two are what every other phase is verified with. So the order of work was the plan's own - verify the inherited tree green first, write the module and its tests, break the tests, then boot - and three things that moving it found are recorded below because they were defects, not tidying.

What was there

In arch_hw.c, under a banner that said "the kernel log":

  • g_kernel_log, a vibeos_log_t ring, written by hw_log from every core;
  • hw_log_format, hw_log_emit (serial), hw_log_to_sink (disk) and its per-core reentrancy flags, hw_log_dump (panic) and vibeos_x86_64_log_dump_recent (the console's log);
  • the crash ring g_crashes[4], g_crash_next, g_crash_count, written from task_life.c and printed by vibeos_x86_64_crash_dump.

The plan's 724 and 56 lines were measured before C4/C5 had already moved some of it; what was actually left was about 470 lines of log section plus the crash ring. arch_hw.c is 6,031 lines after this phase (6,231 before it).

What it is now

include/vibeos/klog.h, kernel/diag/klog.c:

  • The ring has its own lock (vibeos_klog_set_lock, registered by the arch as hw_spin_lock_named(..., "vibeos_klog")). The lock covers the ring only; sinks are written outside it.
  • A sink is a registration: a name, the quietest level it wants, an optional prefix, whether its lines are numbered and newline-terminated, and write(ctx, line, len) that takes one complete line and says whether it took it. The serial port ("serial", INFO and up, [LOG], numbered) is registered by hw_klog_init; the disk ("disk", every level) by hw_logsink_bringup, and only once the medium has taken its boot mark - before that it would refuse every line, and every refusal is a must-be-zero.
  • One line, one call. The module formats the whole line and hands it to each sink once; the serial sink passes it to one serial_puts, which is one critical section on the console lock. The "multi-part message left unbracketed" defect is not guarded against here, it is not expressible: there are no parts.
  • A refused line is counted: per sink (lost) and in the registry as klog_line_lost, which the boot gate asserts through [MBZ] MUSTBEZERO total.
  • A line the device lost while saying it had not is the half no counter inside the kernel can see. Every serial line carries ln=0x..., one sequence per sink; the [KLOG] line reports how many were given out; the gate requires every number from one to that count to be in the log (klog_line_missing, klog_counters_missing, klog_serial_unproven).
  • Reentrancy is the module's, for the sinks that ask for it and keyed by execution context (see "The limits it shipped with", below): an event raised inside a sink's own write (the block layer logs when it refuses a request) is recorded and reaches the other sinks, is not offered back to the sink that raised it, and is counted as reentered.
  • vibeos_klog_panic(why) writes "[FATAL] PANIC: why" to every sink with no lock and no level; vibeos_klog_dump_unlocked replays the ring for a panic with no lock; vibeos_klog_dump_recent is the console's log, one event per critical section.

include/vibeos/crash.h, kernel/diag/crash.c: the ring of four, its own lock, a record copied whole in and out, registers as name/value pairs the architecture fills, and a portable dump that prints exactly what crash printed before. The capture - reading the trap frame, and the user stack through vibeos_uaccess_copy

  • stays in task_life.c, because that is what a trap frame is.

What stayed in the arch layer, deliberately: the UART itself, the frame-pointer walk (hw_backtrace), parking every core on a panic, the raw deadlock reporter that must not take the console lock, and the [HW][TRAP] line.

What moving it found

1. The ring was never locked. hw_log called vibeos_log_record from every core with no lock, then asked the ring for vibeos_log_latest to decide what to print. With two cores logging at once, "the latest" is whichever landed second: one line printed twice, the other never, and both could claim the same slot. The event printed now is the copy taken inside the same critical section as the record. Same shape as backing.c in CLAUDE.md - a layer serialised by accident - and the fifth layer in this project to need its own lock.

2. The crash ring was not locked either. The recorder read the cursor, filled the slot and advanced the cursor as three steps; two processes faulting on two cores (which is what a broken library does to everything linked against it) wrote one record over the other and advanced past a slot nobody filled.

3. The ring held a false FATAL every boot. The trap model was handed the ring directly, so it wrote behind the lock and past every sink - and, asked with pid 0 and knowing nothing of privilege, it recorded kernel_fault_panic at FATAL for every ring-3 fault, including svc-crash's deliberate one on every boot. A panic dump would have shown a FATAL for a fault the machine had survived. The model is now given no log; the arch logs kernel_fault_panic itself, only on the path that actually panics, with the same code (TTFK) and a1 = rip that the gate's poison-executed detector reads.

And one the new torture found in the new code on its first run: the panic path had its own copy of the line builder, and that copy dropped the newline when a line had to be cut. Unreachable with today's [LOG] prefix, and still two copies of one rule already disagreeing. There is one builder now.

How it is proved

  • Host tests (tests/kernel/klog_tests.c, crash_tests.c): one write per event per sink with the exact text; the ring locked exactly once per event and a sink never written under it; a second core simulated at the unlock, so "print the latest" is caught deterministically; levels; refusals counted with the witness naming the sink; reentrancy; truncation keeping its newline; both dumps; panic; a full table; lock balance across the whole test. Crash: the exact dump, ring of four, clamping of a record that lies about its sizes, one critical section per record.
  • Torture (tests/kernel/diag_torture.c, nightly job diagnostics-torture, # module: diag): random sinks, levels, refusals, nested reentry, a simulated second core, panics, dumps and crash records, every line checked against a model that formats with snprintf and shares no code with the module; long runs must wrap the 2048-event ring (the first version never did - a reset every hundred rounds kept it under capacity, and the torture now fails a long run that did not wrap).
  • Sabotage, all red by name:
    • diag-klog.txt (8 cases: an unmatched unlock; a line in two writes; a refusal ignored; the ring unlocked; "latest" read after the unlock; sinks written under the lock; no reentrancy guard; one line counter shared by all sinks) - red under the host tests and under the torture alone.
    • diag-crash.txt (4 cases) - red under both. The register-count case first went red under the torture as a segfault with no message: the model had named it, and the buffered line died with the process. The torture flushes on failure and stops dumping a record it already knows is wrong.
    • diag-sink.txt (4 boot cases: the serial sink drops its fifth line and reports success; the [KLOG] line disappears; the sink is offered nothing; the sink logs from inside its own write) and crash-recorder.txt (re-pointed: its first case targeted the arch's own ring, which no longer exists) - red under the boot gate. The dropped-line case first dropped one line in sixteen and went NOT RED: a boot hands the serial sink about a dozen live lines, and the sixteenth call was a replay from the console's crash/log dump, which carries no ln=. That is the detector's real limit and is recorded in the case file rather than tuned away: ln= covers live lines; a lost replay line is seen only by the markers the gate waits for on those commands.
  • check-subsystem.py covers kernel/diag (diag/klog reports through the registry; diag/crash is exempt with its reason, the exemption count raised from 23 to 24 as a decision).

The plan names the file core-diag.txt; check-subsystem.py pairs a module with its cases by area, so they are diag-klog.txt, diag-crash.txt and diag-sink.txt.

The limits it shipped with, closed (2026-09-23)

The first version of this file listed three limits as open. All three are closed; closing them found one more defect.

  • The reentrancy guard was per core, and a task can move. Between the module setting a sink's busy flag and the sink's write, interrupts are on, and kernel tasks are preempted and migrated; a task moved there left the flag set on the core it had left, and every other context on that core had its lines to that sink skipped as reentered. Masking interrupts across a disk write was not an option - a core with IF clear cannot answer a TLB shootdown. Two changes instead: the guard is opt-in per sink (guard_reentry: on for the disk, whose block layer logs; off for the serial port, which cannot recurse - so nothing can ever skip a serial line), and it is keyed by execution context (vibeos_klog_set_context): the arch answers with the running task's slot, read through the per-CPU block only once GS.base is installed, and one number per core before that. A migrated task takes its flag with it; an interrupt nested in a task is that task, so a nested write is still caught. The gate still asserts the serial sink's reentered is zero.
  • Replays had no detector. ln= covers live lines only. Each replay now states its length - log and the panic dump already did in their header; crash now ends [CRASH] end lines=0x.. - and the gate's replay_incomplete counts the lines against it (diag-sink.txt drops one line of the crash dump: red as replay_incomplete=crash_dump).
  • There were two logs. kmain kept a ring of its own for its boot stages, so status counted one log, log printed both, and a panic dump never showed a boot stage. kmain now logs into klog, which on the machine the architecture has already created (entry.s runs hw_early_init, whose "log" stage creates it, before vibeos_kmain); kmain only creates it when it is missing, which is the host tests. vibeos_kernel_t.log is gone. The host boot test logs one event "as the architecture" first and fails if kmain throws it away (diag-kmain.txt).

Found while closing them: the console's log showed the wrong events under load. dump_recent fixed what to show as a range of ring indices and re-read them one lock at a time. With the ring full - every boot past 2,048 events - each event another core logged during the dump pushed the oldest out and moved every index by one, so the dump showed a neighbour instead: the same event twice, one skipped, nothing said. It now fixes a range of sequence numbers under one lock and fetches each by number; an event overwritten before its turn prints as [LOG] #<seq> overwritten before it could be shown, so the line count is still the one the header announced. A host test fills the ring and logs during the dump; the torture does the same with bursts large enough to overwrite announced events, and requires a long run to have done so.

Code scanning

The nightly torture's first version built its expected lines with n += snprintf(buf + n, cap - n, ...), three alerts at high severity. The analysis was right about the mechanism: snprintf returns the length it would have written, so after one truncation n passes cap and the next cap - n wraps to an enormous size_t. No expected line came near its buffer, so it never fired - and nothing stopped the next longer dump from overwriting the torture's stack. One bounded app() helper replaces every such call, and a model text that does not fit is a test failure rather than a silent cut.

Triaged in the same pass: the ABI registry test registered stack arrays with a registry that keeps the pointer (the tables are static now, and abi.h states the lifetime contract); ntfs.c named $INDEX_ALLOCATION's length attr_len inside a function whose outer attr_len is $INDEX_ROOT's - renamed alloc_len, because in a parser that is how one attribute's bound gets checked against the other's.

Still open

  • The panic line reaches every sink, so the serial log carries [LOG][FATAL] PANIC: <why> just before the existing FATAL: <why>, halting line. The gate reads the second; the first is what the disk has always received. Recorded, not a defect.

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 (or io_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 the vibeos_devices linker section; kernel.ld collects the section between two symbols and arch_hw.c hands the table to the registry. Nothing names a driver.
  • The registry does what arch_hw.c and kmain.c did 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 is device_stray_vector - about 5.9 million of them, because a level-triggered line whose handler never runs is never acknowledged - and ahci_interrupt_never_fired; the vector ignored (the driver polls, and the disk still works, which is exactly why only the counter can say so) is ahci_interrupt_never_fired; the count leaving its line is ahci_irq_counter_missing.
  • io-device-block-vblk.txt (virtio-blk, 2 cases): blk_interrupt_never_fired and blk_completion_counters_missing.
  • io-device-boot.txt case 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.

  1. 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 /vol1 reaches 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 says FAILED: also on the root.
  2. The gate could not see that. Instrumented, the sabotaged boot printed result=FAILED: also on the root and the gate still passed: the assertion was "result=OK" not in text, anywhere in the log, and [IO] LOGSINK result=OK is 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 ..=OK tokens 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:

  1. A header, include/vibeos/gui.h.
  2. 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.
  3. 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 in arch_hw.c. The provider has one extra duty: lock returns -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.
  4. Counters with two must-be-zeros: term_overrun (a grid write out of bounds, refused) and guard_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_checks says it ran.
  5. An init that refuses by name: no framebuffer; too large; too small; a back buffer with no room for the canary; already active.
  6. The registry's DISPLAY class: the console writes through vibeos_display_putc and the timer repaints through vibeos_display_tick; neither names a driver.
  7. Case files: io-gui.txt (host), io-gui-torture.txt (the model and threads), io-gui-boot.txt and io-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 is 25 - 26, which is 0xFFFFFFFF, and the clamp if (y + h > g_h) wrapped y + h back under the screen - so the fill ran on. Width had the same shape below 10 pixels. No real screen is that small; init accepted 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.c to kernel/io put 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 with registered=0x5, the text console took the framebuffer, and the gate said gui_counters_missing. Such drivers are listed in VIBEOS_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 fails gui_refused:<reason> when the text console had to take the framebuffer.
  • [FB] no framebuffer; console is serial-only was 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_shared and backbuf_lost were must-be-zero and asserted by nobody. guard_broken led their line and check-mustbezero-asserted reads 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 in check.sh; the nightly job gui-torture runs 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, and device_table_short at 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 - is 71094 characters sent, 37897 counted from 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) and io-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.txt case 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 pointBefore C7Now
input device41
network interface41
block driver41
filesystem (registered)41
display31
filesystem (direct)41 (2026-09-27)
syscall22

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_t has one opaque fs_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's sectors - 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.

Taking the dead code out, first increment

kernel/mm/vm.c and kernel/core/interrupts.c are gone, with their headers, their three syscall handlers, and their tests. 621 lines of kernel and three test functions.

They were not dead. They were built and never consulted

That distinction is the whole point, and it is worse than "dead code".

The live vibeos_kmain — the function that prints BOOT_OK — called all three of these on every boot:

vibeos_vm_init(&kernel->kernel_aspace);          /* line 42  */
vibeos_intc_init(&kernel->intc);                 /* line 80  */
vibeos_intc_bind_timer_irq(&kernel->intc, ...);  /* line 91  */

And nothing else in either file was ever called from anywhere the machine reaches. vibeos_intc_dispatch in particular has no caller outside its own file, so the timer handler that bind_timer_irq registered could not fire. The arch layer's IDT, initialised a few lines above it, is the one that runs.

So this is not a corner nobody visits. It is "configured and consulted by nobody" — the defect docs/core/ names as the one this project produces most often — at the scale of two subsystems, executed on every boot.

The three syscalls that went with them

VM_MAP, VM_UNMAP and VM_PROTECT operated on kernel->kernel_aspace, an abstract maps[] array that was never the machine's page tables. Whatever they returned said nothing about any mapping any process runs on. An external review flagged vm.c for exactly this — that an abstract model should not be read as hardware enforcement — and classified it correctly as informational.

What it cost on the test side, measured before cutting

kernel_tests.c had 64 references. They split cleanly:

test_vm, test_vm_user_address_space_contract, test_interruptsonly touched the removed subsystems — 0 other vibeos_* references — removed whole
test_syscalls2 references against 290 others — the VM syscall block removed
test_timer_and_idt4 against 22 — rewired

The timer coverage is preserved rather than lost: vibeos_timer_on_irq is what the removed handler called, and the test drives it directly now.

And a mistake made rewiring it

vibeos_timer_on_irq returns 1 when the divider is satisfied and it ticks, 0 when it only accumulates, −1 on error. The removed handler discarded the value with (void) and the test checked vibeos_intc_dispatch instead, so the three-valued return was nobody's problem until it became the call being made. Replacing it with != 0 turned a tick into a failure. It is < 0 now.

What the removal proved about the tool that should have found it

check-reachable.py never flagged either file, and could not have.

It asks "is this function named anywhere outside the file that defines it?" — and these were named, by kmain.c and syscall.c, both of which are themselves unreached. The question cannot see "named only by code that is itself dead".

That limitation was written into its docstring as a deliberate trade: a real call graph would have to understand function pointers, which every registration seam uses, and would report the frame layer's lock as unreachable. The trade was the right one and it still cost this. The stronger version — a transitive walk seeded from the arch layer's entry points, treating any function whose address is taken as reached — is now recorded in that file as its next improvement.

The baseline is tightened to 12 in this change, from 27. It has been 12 for days; lowering it is a decision, and this is the commit that earns it.

What is left, and the order

lines
kernel/core/syscall.c1502C3's decision — this is where the ABI vocabulary might live
kernel/proc/process.c1142same call
kernel/core/kmain.c914this claim was wrong - see the second increment below

kernel_tests.c is 9,028 lines and still holds roughly 330 references to that half — most of the largest test file exercises the kernel that does not run, which is part of why it is always green.

C3 says the choice between deleting and integrating is made by asking which produces the smaller diff in C4. The measurement for that now exists.

Second increment: kmain.c, and the claim it corrected

The previous section said kmain.c has "17 dead functions around one live entry point". That was wrong, and it was wrong by the exact method this file had just finished criticising.

It came from grepping which of its functions the arch layer names. Only vibeos_kmain matched, and the conclusion drawn was that the other seventeen were dead. But vibeos_kmain calls them. The question "who does the arch layer name" answers reachability only for the first hop.

A transitive closure seeded from vibeos_kmain alone reaches 16 of the 18. kmain.c holds the kernel CLI - the vibeos> prompt, status, meminfo, log - the boot log, and the boot-failure path. All of it runs.

Acting on the earlier claim would have deleted the CLI.

What was actually unreached: two functions, 47 lines of 915

vibeos_kernel_dispatch_trap wrapped vibeos_trap_dispatch_ex and added a KILL_CURRENT -> vibeos_proc_terminate step. The arch layer calls vibeos_trap_dispatch_ex directly and kills through hw_fault_kill_current_user, so this duplicated live functionality and no fault on the machine could reach it. It is also where the third review's process-slot finding lived.

vibeos_kernel_boot_health was an accessor for boot_health_flags. Checked before removing it, because the CLI prints health= on every boot and breaking that line would have been a silent regression in something visible: the CLI reads the field directly at kernel_cli_print_status, not through the accessor.

Their tests went with them - one whole test function that drove a path no fault can take, and one call replaced by the field it was reading.

The point, which is not the 47 lines

621 lines came out of the first increment and 47 out of this one, and the difference between them is entirely in the analysis. The same file looked like "834 lines of dead code" under the weak question and is "two functions" under the right one.

That is the argument for the transitive walk recorded in check-reachable.py as its next improvement, stated more precisely than it was there: the weak question is not merely incomplete, it errs in both directions. It missed vm.c and interrupts.c entirely - two subsystems built on every boot and consulted by nobody - and it would have condemned a live command-line interface.

Third increment: syscall.c, and a capability model that was never there

kernel/core/syscall.c (1502 lines) and kernel/core/syscall_policy.c (23) are gone, with test_syscalls (687), four functions in user/lib/user_api.c (52) and 18 lines of surgical edits elsewhere. 2282 lines.

Why this one needed a decision rather than a grep

The first two increments removed code that was unreachable. This one is different: C3 asks whether the portable syscall layer should be deleted or integrated, and the rule for answering is which choice produces the smaller diff in C4 - where the Linux ABI stops being 41% of arch_hw.c.

So the measurement came first, and it went against integrating:

kernel/core/syscall.ca switch, dispatching on VibeOS syscall numbers
arch_hw.c's Linux tablea switch, dispatching on Linux syscall numbers

Two switches. C4 wants a table - a syscall id mapped to the checks that apply to it - and neither of these is one. Integrating would have meant carrying 1502 lines of the wrong shape into the change that is supposed to fix the shape.

The 23 lines that were worth keeping, and are kept as prose

syscall_policy.c was the right shape: vibeos_syscall_policy_for(id) returning the checks a syscall requires. It is now written into docs/core/architecture.md as C4's starting form, because a 23-line file consulted by nobody is worth less than a paragraph read by whoever does C4.

That is the trade this increment makes explicit: a design does not have to be compiled to survive.

What the removal established, which is the actual finding

Chasing the callers of syscall_policy produced a fact worth more than the 2282 lines: vibeos_sec_* appears zero times in the arch layer.

The kernel that boots has no capability model at all. The one in kernel/core/ was a complete, correct, tested model that nothing on the machine consulted - the project's most-repeated defect, again, at the scale of a security subsystem.

C4 is therefore not "move the policy layer down". It is "there is no policy layer on the metal; write one". Deleting the file is what made that sentence sayable.

Two failures on the way out, both of the same family

The tests went red, and both reasons were more interesting than the removal.

A capability flag outlived its implementation. vibeos_user_api_capabilities() still reported supports_boot_event_signal, supports_process_security_label, supports_process_interaction_check and supports_policy_summary as 1 after the functions behind them were removed. This is "configured and consulted by nobody" inverted: not a mechanism nothing reads, but a declaration nothing implements - and a capability flag is read precisely to decide whether a function exists.

The assertion was inverted, not deleted: it now fails if any of the four comes back non-zero. A removed feature that still advertises itself is a defect whether or not anything currently believes it.

An assertion on the effect of a removed function. test_user_api_and_bootloader asserted vibeos_event_is_signaled(&kernel.boot_event)

  • the effect of vibeos_user_signal_boot_event, which had just gone. Nothing signals that event now, so the assertion was not lost coverage; it was an assertion about a function that no longer exists. Removed.

What is left

lines
kernel/proc/process.c1142deliberately not touched

process.c is the subject of C5 - "one owner of what a task is". Deleting it now would decide C5 sideways, in a dead-code commit, without the comparison C5 exists to make. The choice between it and hw_task_* is the phase, not a cleanup.

And the removal was reported finished twice before it was

Both times the evidence was a binary rather than the tree.

The stale assertion above was described as removed while it was still on line 884. What appeared to confirm that was running build-gcc-Release/vibeos_kernel_tests by hand: it printed ALL_TESTS_PASS, and it was built from an older tree - the verification script builds clang only, so the gcc binary beside it had never seen the change. Third instance here of "a green build is not a build", and the first where the stale artefact was reached for deliberately, as a second opinion.

The wrapper made it easy. It printed host-tests=FAIL kernel=1 on line 11 and [exited with code 0] on line 18, because the script's own exit status was the last echo. A summary whose verdict contradicts its exit code is a summary nobody reads carefully twice. It now checks every line it prints, ends with VERDICT=green|RED, and exits non-zero - the same rule check.sh already follows with rc=, applied one level up.

The first performance measurement this kernel has ever had

C0 of the core plan. Before this there was no benchmark, no timing, no budget and no regression gate, so every statement anyone could make about this kernel's speed — including a reassuring one — was invented.

The refactor adds indirect calls to the syscall, fault and switch paths. Without a baseline it could not have been shown not to have cost anything, in either direction, since an unmeasured reassurance is worth exactly what an unmeasured alarm is.

What is measured

Three hot paths, instrumented at one site each:

PathWhereWhat
syscallvibeos_x86_64_syscall_dispatchcount, total cycles, minimum
page faultthe vector-14 call site in vibeos_x86_64_isr_handlercount, total cycles, minimum
context switchhw_task_load_cpu_statecount only

The syscall is timed at the dispatch rather than inside the handler, because what the refactor changes is the classify/marshal step an ABI translator adds — timing only the handler would miss precisely the thing being introduced.

The fault is timed at the call site rather than inside hw_handle_cow_fault, which has five returns: at the call site the measurement is one pair of reads and cannot miss an exit path. Only resolved faults are counted; an unresolved one ends in a kill or a panic, and folding those in would make the number drift with how often the machine crashed.

The switch is counted and not timed. That function is the switch — it ends by installing cr3 and returning into the next task — so there is no second point at which to stop a clock.

Two things the first measurements corrected

The mean is not a baseline. The first boot read 46 million cycles per syscall. That is not dispatch cost: a syscall that blocks is timed across the block, so the number was reporting how long waitpid waited. It would have moved with anything that changed scheduling — the opposite of what a performance ratchet is for.

The minimum replaced it. A blocked task cannot lower a minimum, and an added lookup or lock on the path raises it. The syscall minimum is around 600 cycles, against a mean sixty thousand times larger.

The counts are not deterministic. They were going to be described as such, and ratcheted on that basis, on the reasoning that the same boot does the same work. Six boots:

bootsyscallssyscall minfaultsfault minswitches
11416—396—516
21381728378807,972497
31383517366736,387461
41370627353799,208458
51352602335622,106426
61370623369595,414453

Syscalls move 4.7% across the range, faults 18%, switches 21%, and the syscall minimum itself 41%. Nothing here can carry a tight ratchet, and a threshold written from the first sample would have been wrong.

This is the memory manager's P2 mistake caught from the other side: that phase was judged for a day and a half against "48 boots with no failure", a criterion measured against a background nobody had characterised. Taking the second measurement before writing the criterion is what this project's own rule asks for, and it is the only reason the false claim did not ship.

The trend that was not one

Worth recording in full, because it happened while this file was being written.

After five boots the counts were decreasing monotonically — 1416, 1381, 1383, 1370, 1352, and faults 396, 378, 366, 353, 335. A monotone run of five is not what scatter looks like, and this file said so: it named a suspect (qemu-cli-logdisk.img, the one piece of state I5b deliberately keeps across boots, whose attach scan grows with it) and called for a five-boot sweep with the image deleted to settle it.

The sixth boot went back up. Faults 335 → 369, switches 426 → 453.

So it was scatter, and the paragraph explaining it was a mechanism invented to fit five points. Nothing was wrong with the reasoning except that it started. The rule this project already has for detectors — the first question is whether the detector is right, and it is never answered by a count — applies to a baseline reading its own numbers, and five samples on a machine with 20% spread is a count.

The log disk remains a reasonable thing to test for drift. It is no longer evidence of any.

What is gated

Only that the counts are non-zero.

That is deliberately weak and it is the assertion this project keeps needing. VIBEOS_BLK_TIMEOUT was defined, printed, asserted by the gate and produced by no driver. A disk-interrupt counter read zero for an hour because its dispatcher hook had never been added, and sent four measurements in the wrong direction. A counter nothing increments and a counter reporting nothing happening are indistinguishable, so before any of these numbers can be reasoned about, something has to establish that they can move at all.

Also gated: cycles accumulated without events, or events without cycles — half a pair is worse than neither, because a mean built from one of the two reads looks like a measurement.

The ratchet is deliberately not here. It needs a characterised spread, and the six boots above are the start of collecting one rather than the end.

Cost of the instrumentation

Two rdtsc and one atomic add per event; the minimum uses a compare-exchange loop, which by construction cannot spin, since each retry means the value strictly decreased. Under TCG that is far below the cost of the event being measured.

One boot in the sweep reported stress_run_did_not_finish before the minimum tracking was added, and the boot before it had passed. Two boots decide nothing on a machine with a known intermittent failure, and the four boots since have all passed with the instrumentation in — so it is not attributed to the instrumentation, and it is not attributed to anything else either.

What this says about the next phases

architecture.md states that registration must be resolved once and not per call, and that an indirect call may not be added to the three hot paths without a measurement showing what it cost. That measurement now exists, and its first lesson is about its own limits: on this machine, under TCG, the noise floor is wide enough that a seam adding a handful of cycles will not be visible. What will be visible is a lock or a per-call lookup, which is the failure the rule was written for.

The fault minimum is around 700,000 cycles, which is large. The copy-on-write fault sends a TLB shootdown IPI and waits for the acknowledgements, so most of that is likely the round trip. Likely, not measured — under TCG the emulated cost of an IPI bears no fixed relation to a real one, and this project has a rule against reasoning about a number like that without a second source.

Revising the kernel core plan

The plan in docs/core/ was written, challenged four times, and rewritten end to end against four requirements: maintainable, modular, observable, fast, with the size of arch_hw.c treated as a symptom rather than a goal.

This file records what the revision changed and, more usefully, what measuring found that reading had not.

The criterion was wrong, and had already failed twice

The first plan's completion criterion was the length of arch_hw.c. That criterion has been used twice in this project and failed both times: once the file grew by 633 lines during a session whose stated purpose was shrinking it, and once by 2,800 during the I5/I5b work — both times while the document naming its length as the criterion sat unchanged in the repository.

A file grows because adding to it is the path of least resistance. Cutting it does not change that. So the criterion is now blast radius: how many files you must edit to add one thing of a known kind.

It is already a controlled experiment rather than a theory:

To add…Files edited today
a filesystem1 (vibeos_storage_register)
a block driver1 (vibeos_blk_register)
a character device, input device, network interface, display, syscall, or ABIarch_hw.c

Four filesystems and four block drivers arrived without their layers being edited. Everything else arrives by making the monolith bigger. The rule that follows was rediscovered independently eight times — there are eight registration seams in the tree, each invented separately by somebody solving one problem — and never named until now: a subsystem is extended by registering into it, never by editing it.

Three things measurement found that the previous plan had wrong

The scheduler phase is already done. hw_pick_next calls vibeos_sched_policy_pick, and falls back to vibeos_runq_pick rather than idling — deliberately, because a task the policy was never told about is an admission bug, and refusing to run it would turn that bug into a hang instead of a slower machine. The plan had it as a future phase. It is recorded as done rather than deleted: a plan that quietly loses a finished phase cannot be audited.

Observability is much thinner than the tooling suggests. The kernel prints 25 distinct report tags and has five must-be-zero counters in the whole tree: frames_leaked, medium, double_free, bad_unlocks, illegal_transition. Against 48 sabotage case files and a 2,020-line boot gate, that is not a tooling gap, it is a coverage gap — most subsystems report what they did and nothing about whether it went wrong. Every hard defect in this project's history was found by a counter or a detector, so observability got its own phase, placed ahead of every phase that moves code.

Performance has never been measured. Not once. No benchmark, no timing, no budget, no regression gate. 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 what an unmeasured alarm is. The baseline turns out to be cheap to start: the boot gate has been recording phase_history since it existed and nothing has ever read it.

The structural claim that reordered the plan

arch_hw.c measured: 10,087 lines in 19 sections. Every section now has a destination in docs/core/architecture.md. Summed, 7,726 lines leave and about 2,360 stay — the parts where a second architecture would need a different answer, not the same answer implemented differently.

The largest single block that leaves is the Linux ABI: the syscall layer (679), the socket syscalls (1,654), what a program needs once running (651) and what a C runtime asks for before it runs (1,185). 4,169 lines, 41% of the file — and it is exactly what the next refactor on the roadmap has to move anyway.

So the file cut and the multi-ABI syscall layer became one phase instead of two. Splitting them was the previous plan's worst decision: cutting first produces four smaller files with the same shape, which all get rewritten when the ABI interface arrives. Building the interface first and moving the code through it does the work once, and is the only version in which the cut is verifiable — code that moves behind an interface either satisfies it or does not compile.

An ABI is a registered translator (classify, marshal, result), never a branch in the dispatcher. Three ABIs as three branches is three copies of every permission check, and the checks are where every security defect in this project has been found.

What is now watched that was not

check-reachable.py is written and running: for every function in the portable kernel, is it named anywhere outside the file that defines it? It reports unreached=12 baseline=27, ratcheted downward only.

It is a deliberately weaker question than "is it reached from the boot path". A real call graph would have to understand function pointers, which every registration seam uses, so it would report the frame layer's lock and the task view as unreachable — the kind of confidently wrong answer that teaches people to ignore a check. "Nobody names it" is coarse, almost never wrong when it fires, and catches every case this project has actually had: a portable dispatcher no process reaches, a scheduler policy the boot path did not consult, a page cache with no caller, four filesystem drivers that had never parsed a byte.

The two entries that matter are kernel/core/syscall.c (1,477 lines) and kernel/proc/process.c (1,115). The arch layer names vibeos_syscall zero times. That code carries three uncorrected defects — thread creation with no check on the caller, a process slot that is never freed, and a caller identity read from an argument the caller supplies — and the plan's hardest gate says nothing there may become reachable from ring 3 until they are closed. Not afterwards; not in the same change.

Three more checks are specified and not yet written: check-subsystem.py (the seven parts), check-blast-radius.py (the table above, ratcheted) and check-chokepoints.py (each security check declares its call-site count, so a second site is noticed rather than forbidden).

The rule this revision is itself an instance of

Every correction above came from running something, not from reading. The scheduler was assumed unreached until grep said otherwise; observability was assumed adequate until the must-be-zero counters were counted; the ABI's share of the file was a guess until the section banners were measured against line numbers.

That is the same rule the case files keep arriving at from the other direction: when a detector fires, the first question is whether the detector is right, and it is never answered by a count. A plan is a detector for what is wrong with a codebase, and this one was firing on the wrong thing.

The copy-on-write page that keeps the parent's contents

The oldest open defect here, and the one the memory-manager rewrite was written for. This file records what is now known about it, because four earlier investigations each ended at a plausible pointer with no mechanism behind it, and two of them were wrong in ways that cost days.

The symptom

svc-stress, round 5 or 6, reports:

STRESS_FAIL: the child's own copy-on-write page at offset 0: found 0x33 expected 0xcc
STRESS_FAIL: verdict=the page was private and changed anyway

0xcc is ~0x33, and the program computes after = ~before, so the child is reading back exactly the value the page held before the fork.

What has been established

A reproduction came first, and everything below rests on it rather than on reading the code.

It reproduces on demand. check.sh all build-clang-Release fails three times out of three. Local gcc builds boot 8/8 clean, which is why this went unreproduced for so long - and it is the second time here that a defect lived in the configuration nobody ran by hand.

It is not a race. Forced to a single core with -smp 1, it still fails three times out of three. That removes every concurrency explanation, including the TLB-shootdown family that had been the leading theory, and makes the defect deterministic enough to reason about.

The copy does happen. The four page samples say so:

Sampleframeowners
parent, before the fork30051
child, after the fork30052
child, after its write30201
child, at the check30201

The frame changes on the child's first write, which is copy-on-write working, and it does not change again.

The frame is private and stays private. owners == 1 at the check, and the frame is the same one the write went to.

The whole page is stale, not one byte. 4096 bytes of 4096 differ. This is the measurement that mattered most and it was the last one taken: the check reports the first byte that differs, which is always offset 0, and that was read for an entire session as "only offset 0 is wrong". It is equally consistent with every byte being wrong, and every byte is. Those are different defects - a few stale bytes is a copy racing a store; a wholly stale page is a write that went somewhere else entirely.

What has been ruled out, each by a detector rather than by argument

  • A frame freed while still mapped. The existing free-side watch was turned up from one free in sixty-four to every free. Silent for the whole boot.
  • A frame handed out while still owned. A new counter in frame_take (double_allocs, printed in MM_STATS and asserted zero by the gate) watches the allocation side, which nothing watched before. Zero.
  • A write to a freed page. poison_hits is zero.
  • Global TLB entries surviving a CR3 reload. There is no global bit anywhere in this kernel and CR4.PGE is never set.
  • The two user windows having different mapping rules. hw_map_low_user_page is a thin wrapper on hw_map_page; they are the same code.

Where the next attempt should start

The child writes 4096 bytes and reads back 4096 bytes of the pre-fork value, from a frame that the page tables and the ownership count both say is its own, on a single core, deterministically. Either the stores did not land in frame 3020, or the reads did not come from it.

The next measurement should therefore not be about ownership or lifetime - both are now instrumented and clean - but about which frame the CPU actually used: have the child write one byte, then ask the kernel to read that page through the identity map and report what it sees. If the kernel sees after and the child sees before, the mapping is right and the child's translation is stale. If the kernel also sees before, the store never reached the frame at all.

Note also that the write loop is compiled by clang at -O2 and the defect is clang-only so far; whether that is a vectorised store pattern or simply a different allocation order has not been established, and should not be assumed either way.

The sharpest fact so far: mappers=2, owners=1

A repeat-boot run caught the release-side watch firing, and its message is the most precise statement this defect has produced in five investigations:

[MM] FREE_WHILE_MAPPED frame=0x2355000 still mapped by pid=0xb
     during destroy mappers=0x2 owners=0x1 owners_now=0x1

Two address spaces map the frame; the ownership count knows about one. That is not an arithmetic error in the count - it is a mapping that was installed without taking a reference, which is exactly the failure the memory-manager rewrite was proposed to make impossible.

It is reported during a destroy, and specifically a destroy of an address space that is not the current task's - the arch-level short circuit for "the dying task legitimately still maps what it is freeing" did not apply. So the path is a task tearing down somebody else's address space: exit being reaped, or execve discarding an old image.

vibeos_vmspace_map_raw takes its reference before publishing the entry, and clone_one goes through it, so neither fork nor an ordinary map is the uncounted one. The next attempt should look for a page-table entry written without going through that function.

Why these counters exist

The condition appears about one boot in sixteen. A message alone therefore makes every run a lottery in which "nothing reported" and "fixed" are indistinguishable - which is how three earlier fixes here were believed. Two counters now make each boot a measurement:

  • double_allocs - a frame handed out while somebody still owns it. Nothing watched the allocation side before, and it is the blind spot that matters: a double allocation frees nothing, so every release-side watch stays silent.
  • free_while_mapped - more mappers than owners, counted rather than only printed.

Both are on the MUSTBEZERO line and the boot gate fails if either is non-zero. The assertion was confirmed to go red by making frame_take count unconditionally: mm_double_allocs=4813.

The free-side walk now samples one release in eight rather than one in sixty-four. A counter that only sees one free in sixty-four cannot be compared between two states, which is the whole point of having it. Eight clean boots out of eight at the higher rate.

Asking a second observer, and what it settled

pageinfo now returns first_word: the first eight bytes of the frame as read by the kernel, through its own mapping rather than through the caller's. The whole value of the field is that it does not use the translation under suspicion. Two runs settled two questions that five investigations had not.

The store never reached the frame. In the failing round the child wrote 188 to all 4096 bytes; immediately afterwards the kernel read 239 at byte zero of the frame the page tables point at - and 239 is the pre-fork value. So the copy is intact and untouched, and the child's writes are simply not in it. The reads are fine: child and kernel agree on what the frame holds.

The page itself works. After the failure, the child stores one ordinary byte, 0x5a, and samples again: child reads 90, kernel reads 90, same frame. The mapping is right, the frame is right, the permissions are right, and an ordinary store lands immediately.

So nothing is wrong with the page. What is lost is specifically the write loop.

The write loop, and why this is not "a test bug"

for (i = 0; i < 4096; i++) mem[i] = after; is not 4096 byte stores by the time clang has had it. Making that loop volatile - so each byte is a separate ordinary store - makes the failure vanish: three runs out of three clean, where the same tree failed three out of three.

What the binaries do contain is movaps and movdqa - ordinary 16-byte aligned SSE stores - and no non-temporal stores at all, so no missing fence explains this. Ordinary SSE stores are cache-coherent and atomic with respect to faults: architecturally there is no reason for one to be lost.

One measurement here was taken wrongly and is recorded so it is not taken again. Counting wide stores across the whole binary gives gcc 18 and clang 172, and that was briefly written down as evidence that clang vectorises this loop and gcc does not. It is not evidence of anything: the same two counts come back unchanged when the loop is made volatile, so they are counting other code entirely and never described the fill loop. The restore that was supposed to put the loop back had failed silently first - the backup was in /tmp, which WSL had cleaned, which is a trap this project has already written down once. The volatile result itself was measured correctly and stands; the disassembly comparison did not and does not.

The loop has therefore not been made volatile, and must not be. A volatile loop would turn the boot green while leaving the defect exactly where it is, and this file already records three fixes that were believed on that kind of evidence. musl's own memset uses the same instructions, so if a wide store to a freshly copy-on-write-resolved page can be lost, it is lost for every program on this machine and not only for the one that noticed.

Where the next attempt should start

The question is now narrow and mechanical: what happens when a 16-byte SSE store, rather than a byte store, takes the copy-on-write fault?

The child's first movaps faults on a read-only shared page. The handler allocates a frame, copies the old one into it, installs the entry and invalidates. The instruction is then retried by hardware. Something in that sequence loses the retried store while a byte store survives it, and the candidates are few enough to check one at a time:

  1. Whether the fault is even reported the same way - the error code, and whether hw_handle_cow_fault accepts it.
  2. Whether the retry happens at all, or the handler returns to the following instruction. A skipped rep-like vectorised block would lose the whole fill, which is precisely the symptom.
  3. Whether the copy runs after the retried store rather than before it.

Instrument the fault handler to log the faulting rip and the fault count for this page. If the page faults once, the store was lost after a successful resolution; if it faults twice, the second fault is the interesting one.

The page faults once, and there is more than one failure here

The fault handler can now log every copy-on-write fault with its faulting rip - built with -DVIBEOS_COW_FAULT_TRACE=1, off otherwise, because a line per fault is hundreds of lines in a boot: it floods the log the gate reads and it changes the timing of the defect it is looking for. An unhandled fault is rare and always worth a line, so that half is unconditional.

It answered the question it was built for. The page that loses a wide store faults once: va=0x8004000000 ... handled, and nothing further. So the store is lost after a successful resolution, not to a second fault that nobody handled.

It also showed something that changes how these runs should be read. The failing round is not always the same defect. One run reported:

STRESS_FAIL: the child's own copy-on-write page at offset 0:
  found 0x00 expected 0x68 - this is the kernel's free-page poison:
  the page was reclaimed while still mapped here

All zeroes, which is what frame_take fills a page with - so that page was properly freed and then legitimately reallocated to somebody else while this process still mapped it. double_allocs is zero, so the allocator was not at fault; something released the frame without the ownership layer noticing.

That is the same family as the mappers=2, owners=1 event, and a different defect from the lost wide store. Both hide behind one sentence from ring 3 - "the bytes are not what I wrote" - which is exactly the confusion pageinfo was added to end, and it is why the four verdicts the stress service prints matter more than the byte values.

Two defects, then, not one:

  1. A wide store lost after a resolved copy-on-write fault. One fault, page healthy afterwards, an ordinary byte store lands. clang only so far.
  2. A frame released while an address space still maps it. Reported both as mappers=2 owners=1 at a destroy and as a page of zeroes handed to a live process.

Previous investigations treated these as one, which is a large part of why each of them ended somewhere plausible and wrong.

The lost wide store is TCG, not the kernel

Two more measurements finished this half.

The child reads the old value from its own page with no kernel in between. Every earlier observation went through page_sample, which is a syscall - so "the child sees the wrong value" had always been seen on the far side of a kernel entry. Reading mem[0] straight after the loop, before any syscall, returns before: the loop wrote 60 to all 4096 bytes and the next instruction to look reads 195.

The parent's page is untouched. 0 bytes of 4096 differ, parent and kernel agree, so the stores did not go to the shared frame either. They went nowhere.

And it does not happen on real hardware. Same binary, same kernel, three runs each:

acceleratoroutcome
TCG21 STRESS_FAIL lines, 3 runs of 3
KVMclean, 3 runs of 3

The argument that settles it does not rest on that table, because this project has been misled by a KVM-clean result before - twenty clean runs once validated a setup that pure TCG broke in two runs out of three, which is why every script here forces TCG. It rests on where the store happens: once the fault handler returns, the faulting instruction is retried by the CPU with no kernel involvement at all. The kernel cannot lose a store it does not take part in. Everything the kernel is responsible for has been shown correct - one fault, handled, a private singly-owned frame, the right contents copied into it, and an ordinary byte store landing in it immediately afterwards.

The strongest evidence is not the KVM run at all - it is in CI, and it holds the emulator constant. Of the five jobs on this commit, exactly one fails:

jobresult
Linux clang / Releasefails - QEMU Boot-to-CLI Smoke
Linux clang / Debugpasses
Linux gcc / Releasepasses
Linux gcc / Debugpasses
Windows Host Testspasses

Same kernel, same TCG, same machine, same test. The only thing that changes between the clang job that fails and the clang job that passes is the optimisation level - that is, whether the fill loop is vectorised. A kernel defect does not know about -O2.

The last piece came free from the disassembly. svc-stress has another operation that fills a fresh mmap page with the same movdqu instructions and checks it, and that one passes. So:

  • wide store, no fault - works
  • byte store, copy-on-write fault - works
  • wide store, copy-on-write fault - lost

which is a statement about restarting a 16-byte store after a page fault, and that is the emulator's job.

What this means for the gate

The boot gate forces TCG, deliberately and for good reasons, so CI will keep failing here until something is done. The options are not equal and the choice is not obvious:

  • Make the fill volatile. One line, and it is exactly the kind of change this file warns about three times: the boot goes green and nothing is understood. It is defensible only because the kernel has now been shown not to be involved, and it should carry a comment saying so.
  • Keep it and accept a permanently red gate, which trains people to ignore it.
  • Narrow the assertion so this specific operation is reported but not fatal.

This is a maintainer's decision, not the investigation's, and it is left open here rather than settled quietly.

What is still open

The other defect is untouched by all of this: a frame released while an address space still maps it, seen as mappers=2 owners=1 at a destroy and as a page of zeroes handed to a live process. That one is real, it is the kernel's, and it is what the memory-manager rewrite exists for.

Seen again, 2026-09-11

Boot 3 of the twelve-boot series that verified interruptible waits:

[MM] FREE_WHILE_MAPPED frame=0x22ba000 still mapped by pid=0x14
     during destroy mappers=0x2 owners=0x1 owners_now=0x1 at_va=0x80003ff000

The same path and the same counters as the signature above, during svc_bomb's fork storm. Not attributed to that change: it touched blocking waits, not fork, reference counts or teardown. The rate on that series - one in twelve - says nothing either way against the one in sixteen recorded here.

What the recurrence adds is at_va, which the original did not carry. The frame is the top page of a native program's stack, in the 0x8000000000 window. That narrows "a page-table entry written without going through map_raw" to whatever builds or copies a native program's stack. It is a direction to instrument, not a finding.

Two checks about the checks

Status: both in check.sh, both sabotage-verified, and the second found a real defect on its first run.

Why

Counting today's work honestly: of the defects found, more were in this kernel's detectors than in the code they watch — a poison check reading uninitialised state, frame accounting sampled from a machine that was still forking, a trap dump reporting the plan instead of the outcome, a deadlock reporter that took the console lock and therefore deadlocked, a diagnostic that cleared its own evidence before it was read.

The tempting response is a framework that all detectors are written against. That is the wrong answer: those five have different causes and only look alike because they were found together, and an abstraction over them hides them better rather than preventing them.

Two of the shapes, though, are mechanical, and both were present in the same defect. VIBEOS_BLK_TIMEOUT was defined, printed by kmain, asserted by the boot gate, and produced by no driver — so the assertion was green by construction for as long as it existed, and it took reading the source to find. Neither half needs judgement to detect.

check-assertions-covered.py

Every name the gate can put in reason=, against every sabotage case in scripts/dev/cases/. An assertion no case mentions has never been observed failing and might not be able to.

115 assertions; 7 covered. That number is the finding.

It is a ratchet, not a target: zero would start red, and a check that starts red is one nobody reads — this project has a rule about that. The baseline is what the tree had when the check was written and may only go down. What it catches from day one is a new assertion arriving without a case, which is exactly how BLK_TIMEOUT happened.

It does not claim that a case was run or that it turned that assertion red — only that somebody wrote one. That is the cheap half, and the half that hides an assertion which cannot fire.

check-counters-produced.py

Every field of every reported stats struct, against every ++, += and non-zero assignment in the tree. A counter nothing increments is an assertion that cannot go red.

It found one immediately: vibeos_rmap_stats.cycles. The boot gate asserts rmap_cycles is zero. Nothing had ever written to it — vibeos_rmap_init did not even reset it — so the check was BLK_TIMEOUT again, in a different subsystem, and had been since the counter was declared.

Worse, the thing it was declared for was real and unguarded: every list walk in rmap.c was while (cur != RMAP_NONE) with no bound. A cycle is not a wrong answer there, it is a core that never comes back — and this kernel has had exactly that, when the node pool was carved after vibeos_frame_init and user pages overwrote the lists, leaving CPU#0 in vibeos_rmap_add for the rest of the boot. The walks are bounded by the pool size now, and hitting the bound is what produces the counter.

It also found vibeos_reclaim_stats.skipped_pinned dead: vibeos_anon_reclaim does refuse pinned frames and never said so, so "reclaim found nothing to take" and "everything reclaim looked at was pinned" were the same silence — and one of those is the kernel holding memory it will not give back.

The exemption list is the point as much as the failures

Eighteen counters are declared and legitimately unproduced: reporting that exists ahead of the phase that will fill it, which is the order this project's plans use deliberately. Each one names its phase. An entry parked there because it is inconvenient would be this check's own defect wearing a permission slip, so a stale exemption — one the tree now produces — fails too.

What it cannot do

It matches by field name across every watched struct, so two structs sharing a name cover for each other: io_stats.cache_hits reads as produced because mm_stats.cache_hits exists, and those are different caches. Fixing that needs to know which struct an expression belongs to, which needs a C parser. The limitation is named in the tool rather than papered over, because a checker that quietly gets this wrong is the exact failure the checker is for.

Confirmed by breaking them

An assertion added with no case: assertions-covered=FAIL uncovered=109 baseline=108. A producer removed: counters-produced=FAIL ... nothing ever increments: vibeos_rmap_stats.cycles.

What is deliberately not automated

"A detector must not depend on the thing it watches." "Evidence must not be cleared before it is read." "A threshold comes from a distribution, not from one measurement." Those are judgements, and they belong in CLAUDE.md with the other lessons — where three of them already were, and were broken today by somebody quoting them.

Diagnostics and Observability Progress

Status: In Progress (six runtime detectors, all gated; they found and closed the premature-free defect that had been chased three times from the far end) Last review: 2026-08-29

Everything here exists because of a specific bug that cost hours, not because it is good practice in general. That is the selection rule: a detector earns a place by being able to name a failure this project has actually had.

The recurring shape of a hard bug in this kernel is that it surfaces a long way from its cause - in a different program, minutes later, as a pointer that makes no sense. Every item below shortens that distance.

Implemented

Poisoned freed pages

hw_free_page fills the page with 0xDEAD0000DEAD0000 before putting it on the freelist, and hw_alloc_page samples the poison when handing a recycled page back out.

A freed page used to keep its old contents, so a use-after-free read plausible data and the program carried on until something unrelated broke. The pattern is non-canonical as a pointer, so the first dereference faults instead of the tenth one corrupting something. The re-check on allocation reports a write to an already-free page at the moment it is discovered.

Gate: use_after_free_detected.

Console-lock hygiene

vibeos_x86_64_serial_unlock refuses an unlock from a core that does not hold the lock, and counts the attempt; bad_unlocks is printed with the memory statistics and the boot fails if it is not zero.

One missing serial_lock() in hw_log_emit freed the lock out from under another core mid-line. The markers the boot gate matches on were cut in half and the gate reported failures that had never happened - two crashes were investigated in full before the split was noticed.

Gate: console_unlock_by_non_owner.

Log-integrity check

interleaved_lines() in the boot gate runs before any other assertion, because every other assertion reads the same text: a split line can invent a failure or hide a real one.

It reports a line when a hex field is cut short (0x with no digits and a tag following - the kernel always prints its values in full) or when a second tag follows a kernel tag. A line opened by a ring-3 write is exempt: a program may write without a trailing newline, and the first version of this check called that a defect and cried wolf on its first run.

It carries self-tests against real lines from real boots, including one that must not be flagged. Known limit, in the docstring rather than left to be rediscovered: a split inside a ring-3 write that truncates no number is not caught here - the bad-unlock counter guards that case.

Found, over three runs: eight multi-part kernel messages assembled without bracketing - [EXEC] three times, then [SIG] deliver, [SIG] tkill, [SIG] tgkill, [SCHED] fork lost its slot and the unimplemented-syscall notice. serial_puts and serial_print_hex each take the lock on their own, so an unbracketed six-part line is six critical sections. All eight fixed; the check kept finding the next one each time the previous was fixed, which is what a working detector looks like.

Gate: serial_log_interleaved(N_lines).

The kernel's own complaints are now assertions

Any [LOG][WARN], [LOG][ERROR] or [LOG][FATAL] line that is not on a short allowlist of deliberately provoked ones fails the boot.

The kernel has had levelled logging for a long time and not one of its warnings had ever failed a build. This is the same lesson as the ring-3 self-test that printed abi: ...wrong for a whole session while the gate stayed green.

The allowlist is deliberately short and specific. Adding a pattern to quieten a real warning is how this mechanism stops working.

Gate: kernel_reported_problems(N).

Crash records

A ring-3 fault is captured at the moment it happens - registers, vector, error code, fault address, the executable's path, and as much of the user stack as is readable - into a ring of four. crash on the kernel console prints the last one in full.

A faulting process used to leave two numbers behind, rip and cr2, printed once and gone. Every hard bug here was then diagnosed by going back for the registers, the stack and which binary the task was running - by which time the process no longer exists. The record names the executable because an address alone identifies nothing when every Linux program links at 0x400000; that omission produced three wrong diagnoses of a single address.

The gate drives crash every boot, because svc-crash faults every boot: a dumper exercised only when nothing has crashed proves nothing.

Gate: fault_was_not_recorded, crash_record_does_not_name_the_program, crash_dump_found_no_record.

Seeded stress service

svc-stress (user/prog/svc_stress.c, staged as SVC_STRS.ELF) runs 120 rounds of randomised fork, anonymous mmap, copy-on-write and pipe work, in a different order each boot, checking the result of every operation. The seed is printed on its first line, so a failure replays: EFI/BOOT/SVC_STRS.ELF <seed>.

The boot otherwise runs one fixed script, which produces the same handful of interleavings every time and can only find a race by luck - which is how the last three were found, one boot in thirty, after a lot of waiting.

Its failures name what they found rather than that something was wrong, and they recognise the free-page poison: "the page was reclaimed while still mapped here" is a different investigation from "the data was wrong".

Gate: stress_seed_not_reported, stress_run_did_not_finish, stress_run_found_a_defect.

Panic records

A kernel panic prints which core noticed, and then one line per online core: its task, pid, state, cr3, and the executable that task was running.

A ring-3 fault has had a full crash record for a while; a panic had a backtrace of the single core that noticed and nothing at all about the other three. Every wedge investigated in this project needed exactly the missing part - which core was on which task, and on whose address space - and it had to be dug out of QEMU's monitor from the host afterwards, when it could have been printed at the time for the cost of a loop.

The per-CPU blocks are read directly rather than stopping the cores with an IPI. This is a machine that is about to halt, and a round trip is the kind of thing that hangs instead of reporting; a slightly stale line is worth more than no line.

Verified against a real panic, not a synthetic one: removing the branch that kills a faulting ring-3 task turns svc-crash back into a machine halt, and the record then names it -

FATAL: unrecoverable CPU exception, halting on cpu 0x2
[PANIC] cpu=0x2 ... pid=0xa state=0x2 cr3=0x21d5000 exe=/EFI/BOOT/SVC_CRSH.ELF

What these found

DetectorDefectStatus
Log integrityeight unbracketed multi-part kernel messagesfixed
Seeded stressmunmap freed copy-on-write pages without consulting the reference countfixed, but the family is not closed - see below

The premature free, and what closed it

hw_sys_munmap freed the frame behind every user page it unmapped, outright, with no frame_ref_dec. That is correct only for a page nobody else has - and after a fork, that is the rare case rather than the common one. Unmapping a copy-on-write page therefore put it straight back on the freelist while another process was still running from it.

This is the same defect that had already been chased three times from the far end, each time presenting as something else entirely: a musl program tripping over its own malloc bins, init printing a pointer where a pid belonged, a forked child reading back a value it had not written. Each investigation ended in a plausible-looking garbage pointer and no mechanism.

What closed it was two detectors meeting. The stress run produced the failure on demand instead of one boot in thirty; the free-page poison told it what the wrong bytes were. The message it printed named the mechanism outright:

STRESS_FAIL: the child's own copy-on-write page at offset 0:
  found 0x0 expected 0x96 - this is the kernel's free-page poison:
  the page was reclaimed while still mapped here

From there the fix was one call, and 48 local boots passed without a recurrence.

It came back. The nightly of 2026-08-30, running a commit that contained the fix, reproduced it: seed 170000012, round 18, the same free-page poison in a child's own copy-on-write page. So munmap was one premature-free path and not the only one, and the claim that the family was closed was made on the strength of local boots that could not see it. Both statements have been corrected here rather than left standing.

Freed pages now also record who freed them: the freelist link takes the first word of a reclaimed page and the caller's name goes in the second, so the next occurrence names the function that released it instead of only proving that something did.

Driving VirtualBox from the harness

scripts/dev/vbox-run.py imports the shipped appliance, boots it headless with the serial line to a file, watches for the boot markers, and takes the guest apart if it stops talking: per-core registers through debugvm getregisters, symbolised with addr2line against the kernel ELF, the guest paging mode, and optionally a full ELF core through debugvm dumpvmcore (about a gigabyte). It removes the VM afterwards unless asked to keep it.

A second hypervisor is not a luxury here. VibeOS could only talk to virtio-blk for months: the appliances imported, booted, and could not read their own disk, and nothing caught it because nothing ever ran the artifact by hand. And VirtualBox inspects a stopped guest better than the QEMU monitor does - a full guest core is the right answer for a wedge, where pushing megabytes through a serial port is not.

python scripts/dev/vbox-run.py            # boot, watch, clean up
python scripts/dev/vbox-run.py --core     # write a guest core if it hangs
python scripts/dev/vbox-run.py --keep     # leave the VM registered

Windows-side, because that is where VBoxManage lives; addr2line is called through WSL, because that is where the toolchain that built the kernel is.

Static analysis

CodeQL runs on every push. Four alerts were open and each was answered on its merits rather than silenced:

AlertVerdict
Comparison result is always the same (arch_hw.c)real. A negate-if-negative in kill() that could never run, because the line above had already forced the value positive. Removed.
Declaration hides variable (hello.c)real, and harmless as it stood. The futex test's word shadowed an outer declaration; renamed.
Local variable address stored in non-local memory (storage.c)real observation about a contract that was nowhere in the code: the block cache must outlive the storage struct. Documented at the assignment; the ownership is still not expressed in the types.
Unused static function (bootloader_core.c:35)not reproduced. region_end has seven callers in that file, and no static function there is unused. Not acted on: deleting a used function to quieten a checker is worse than the alert. Needs the CodeQL run's own log to settle.

Crash collector

scripts/dev/report-crash.py reads crash records out of a serial log and collects them into a single GitHub issue, one comment per distinct crash. It is the "send a report" every desktop OS has, with three rules that keep it useful rather than noisy:

  • The guest never sends anything. VibeOS writes a crash record to its console and stops there. The reporter runs on a host, on an explicit command, on a log somebody chose to hand it. An operating system that uploads on its own is a different kind of thing from one with a bug reporter.
  • One issue, never more. Reports are comments on a collector issue found by its exact title. The script will not open a second one.
  • Deduplicated by signature - the program, the vector, and the faulting instruction, deliberately not the pid or the stack contents. A crash that recurs for a fortnight edits its own comment, bumping a count and a last-seen date, instead of adding fourteen. It also skips the crashes the boot provokes on purpose (svc-crash faults every single run), and refuses to open more than three new reports in one invocation.

Nothing is posted without --post. Without it the script prints exactly what it would send, which is also how to check what a dump contains before it becomes public: a crash record carries register values and a slice of the process stack.

python scripts/dev/report-crash.py qemu-cli-serial.log

Wired into the nightly workflow only, and only on failure, with issues: write declared explicitly on the job - the default token permissions depend on a repository setting, and a reporter that silently cannot post looks exactly like one with nothing to report.

A seed with no entropy is not a random test

The stress service seeded itself from clock_gettime, and the seeds it produced here were 220000010, 230000012, 180000010, 160000010 - the same value give or take a coarse tick. Every boot therefore explored very nearly the same sequence, which is the one thing a randomised test must not do. Fifty local boots stayed green while CI failed five times out of five on sequences this machine had never tried.

It reads rdtsc now, which changes every cycle and is available from ring 3; the clock and the pid are still mixed in to separate two boots that start on the same cycle count. Seeds are eleven digits and different every run.

Still not reproduced locally. The configurations are identical - same Release flags, same TLS setting, and it fails under both gcc and clang in CI - so the difference is the machine. Rather than keep guessing from here, the gate now prints every STRESS_FAIL and STRESS_SEED line when the stress run fails, and the crash collector files stress failures as well as faults. The environment that can see the defect is the one that gets to describe it.

The boot sequence is inside out, and it was hiding every userland failure

Asked plainly - "don't you have a boot sequence, kernel first and then the services?" - the honest answer turned out to be no. The real order, read off a healthy log rather than off the code:

linewhat happens
72[HW] early init: loading GDT — the arch layer starts
107[SCHED] scheduler live — the entire userland runs: init, every service, the self-test, BusyBox, the shell, ping
444[SCHED] all user tasks retired
449[BOOT] VibeOS kernel starting... — the first line of vibeos_kmain
453-457memory stage, scheduler stage, BOOT_OK, CLI_READY

entry.s calls vibeos_x86_64_hw_early_init(), and that name is the whole problem: it does descriptor tables, paging, APIC, SMP, the block and network drivers, the filesystem, spawns init and runs the machine to completion. Only then is vibeos_kmain entered, which brings up the portable memory manager, timer, interrupt controller and scheduler that nothing will ever use, and prints BOOT_OK.

So BOOT_OK is not a boot marker. It means "everything already finished".

Two consequences, both of which cost real time before this was noticed:

  • Every userland hang was reported as a bootloader failure. The gate waits for BOOT_OK first, and until it arrives the phase stays at the last bootloader marker. phase=bootloader_exit_boot_services was chased as a firmware problem for an entire session; the guest was always well past the bootloader, running userland. phase=busybox_cat was the same illusion: the guest was hung in ping, several commands later.
  • kernel/core/ is initialised too late to matter. It is host-tested, its stages are recorded, and the machine has already done all its work on the arch layer's own structures by the time any of it runs. There are effectively two kernels, and the portable one is a report.

Fixed here for now: the gate has kernel_early_init, userland_running and userland_finished phases, so a hang is attributed to the part of the system that is actually hung.

Not fixed: the ordering itself. vibeos_kmain should run before userland, not after it, and hw_early_init should be split into bring-up and run. That is a structural change and is recorded here rather than attempted at the end of a long session.

Logging, and why there was so little of it

Three separate gaps, each of which cost real investigation time before being noticed:

The boot sequence was inverted and unlogged. Fixed: entry.s now calls hardware bring-up, then vibeos_kmain, and vibeos_kmain starts userland after announcing BOOT_OK. Every stage says its own name in one format - [BOOT] STAGE gdt, idt, traps, trap_selftest, syscall, timer, apic_smp, physical_memory, log, block_device, network, hardware_ready - followed by BOOT_OK, USERLAND_START, USERLAND_DONE, CLI_READY. The gate asserts the order, including that BOOT_OK precedes USERLAND_START, so the inversion cannot come back quietly.

The ring held 64 events. That is about a tenth of a second of a running system, so any history was gone before anyone could look at it. It holds 2048 now - 256 KiB, against 440 MiB of RAM - and a boot ends with around 500 events in it.

Almost nothing was recorded. The 27 existing hw_log calls were nearly all refusals and failures; the lifecycle was printed to the serial line and nowhere else, so it vanished when the console scrolled and never appeared in a panic dump. Task exit, execve, fork, signal delivery, copy-on-write copies and the munmap frame release are recorded now, at DEBUG - written to the ring, not printed, which is what makes recording all of them affordable.

And the console showed the wrong log. There are two rings: kmain records boot stages into vibeos_kernel_t.log, while everything the machine does goes into the arch ring. log on the console printed the first - eight boot stages and none of the history. It prints both now, and the boot gate runs the command and asserts the arch ring is not empty, because a log nobody exercises is a log nobody can rely on.

Pending

  • munmap does not shoot down the other cores' TLBs. The need is real - a thread of the same process elsewhere keeps a translation for an address whose frame has just been handed back, so it can write into memory that now belongs to something else. It was implemented and then removed: two runs in twenty-four failed with tlb_acks below shootdowns, a core that never answered. syscall clears IF, so a target cannot take the IPI until it returns to ring 3, and munmap is called far more often than fork - the stress run alone calls it a hundred and twenty times. Trading a rare correctness gap for a frequent stall is the wrong trade. The honest fix is for the syscall path to stop masking interrupts for its whole duration, which is a larger change.
  • A hang in the bootloader phase, about one boot in twenty-four, still uninvestigated. It is not slowness: a 300-second budget leaves the guest silent for 296 of them.
  • No host-side memory dump wired into the harness. QEMU's dump-guest-memory and VirtualBox's VBoxManage debugvm dumpvmcore both exist and neither is used; a full guest core is the right answer for wedges, and pushing megabytes through a serial port is not.

Memory-manager torture (2026-08-30)

tests/kernel/mm_torture.c, driven by scripts/dev/mm-torture.sh, runs the frame layer and the address-space layer through long random sequences of create, map, remap, share, unmap and destroy - and compares every frame's owner count against a model kept independently in plain arrays, after every single operation.

The comparison against a separate model is the whole point. Every hard defect in this subsystem has been a reference count that was self-consistent and wrong about the world: the kernel believed nobody owned a frame while a live process was running from it. Asserting the kernel against itself cannot see that, which is why four fixes did not close it. A model that shares no code with the thing it checks can.

It also asks the premature-free question directly - is any frame the model still maps sitting on the free list? - instead of waiting for the damage to surface somewhere unrelated, which is how that defect was found the first four times and why each hunt cost a session.

The seed is printed on the first line and taken as the first argument, so a failure is replayed with vibeos_mm_torture <seed> <rounds> rather than investigated as a story about something that happened once. The message names the round, the frame, the count the kernel has and the count the model expects.

Where it runs:

  • check.sh runs one short sequence, so a change that breaks the memory manager outright fails on the machine that made it. It is part of the host-tests= verdict rather than a line above it - a distinction this project has already paid for once.
  • The nightly memory-manager job runs 150 seeds under AddressSanitizer and UBSan, then 1200 seeds unsanitized at 20000 rounds each. Two builds on purpose: the model catches wrong arithmetic, the sanitizers catch walking off the descriptor table while doing it, and neither finds the other's failures.

Confirmed by breaking the code it protects. A destroy that skips the low-window tables failed 20 seeds out of 20, each naming the round and the frame; an unmap that forgets to release the frame fails it as well. On the unmodified tree, 120 seeds at 3000 rounds and 40 seeds at 4000 rounds under the sanitizers are clean.

The memory-manager layering check (2026-08-30)

scripts/dev/check-mm-layering.sh, run by check.sh on every build, which prints mm-layering=ok alongside rc= and warnings=.

It fails if anything outside kernel/mm/ writes a page-table entry or takes a reference on a frame, or if the bootstrap bump allocator is called from anywhere but hw_pmm_bringup.

The reason it is a build step and not a review note: the property it guards - one place decides what an address space owns - is exactly the kind that erodes one reasonable-looking line at a time. Every defect this subsystem has produced came from a second place making that decision independently, and each of those places looked entirely sensible where it was written. Nobody added them carelessly. A grep is what stops the sixth one.

Two false positives were removed before it was wired in: uint64_t *pte = ... is a declaration rather than a write, and a comment mentioning vibeos_pmm_alloc_* is not a call. A detector that reports healthy code is one people learn to skip, which this project has already built once and does not need again.

Confirmed to fail by adding a single vibeos_frame_get to the architecture layer.

Driver Framework Progress

Status: In Progress (virtio block/network, AHCI/SATA and PS/2 runtime drivers verified; both disk controllers gated in CI) Last review: 2026-08-29

Implemented

  • User-space-first driver framework scaffolding in user/drivers/driver_framework.c.
  • Driver host/device manager integration stubs in user/devmgr/driver_host.c and user/devmgr/device_manager.c.
  • Driver registration/runtime state helpers in public interfaces (include/vibeos/drivers.h).
  • Basic service manager interaction hooks for supervised startup paths.
  • Versioned driver registration path with ABI gating and isolation caps (vibeos_driver_register_versioned).
  • Fault-state lifecycle support (VIBEOS_DRIVER_FAULTED) with state counters and fault accounting.
  • Runtime x86_64 drivers now include virtio-blk storage, virtio-net networking, PS/2 keyboard input, PS/2 mouse input, serial console, framebuffer output, legacy PIC/PIT and local-APIC support.
  • The QEMU runtime uses virtio block/network devices for the booted OS path; this is distinct from the user-space driver-framework model.
  • PS/2 mouse on IRQ12 (kernel/arch/x86_64/mouse.c). The controller multiplexes two devices onto one pair of ports, so every mouse command is prefixed with 0xD4 and its data arrives on the keyboard's port, distinguished only by which interrupt fired. Two things are handled rather than assumed: the three-byte packets are a stream with no framing, so bit 3 of the first byte - always set - is checked as the only resynchronisation point there is; and movement is sign-and-magnitude, with the sign in the status byte, so reading the byte as a signed char gives a cursor that moves the right distance in the wrong direction half the time. Every wait on the controller is bounded, because a missing or wedged PS/2 controller must not hang the boot.
  • A framebuffer desktop (kernel/arch/x86_64/gui.c) over the UEFI linear framebuffer: a desktop, a panel, a pointer that follows the mouse, and one window that mirrors the console, so the machine shows on screen what it has been saying on the serial line. It composites into a back buffer supplied by the caller and copies only what changed - drawing straight into video memory makes a moving pointer flicker and leave fragments, and framebuffer memory is uncached write-combining, so the read-modify-write that erasing a cursor needs is unusually expensive there.
  • What a serial log can say about the picture is reported as [GUI] GUI_STATS frames=... termchars=...: pointer repaints happened, so the mouse produced packets, and characters reached the on-screen terminal, so the console really is mirrored. The boot gate fails on gui_terminal_empty if a desktop came up and the terminal stayed empty. The pixels themselves are not gated - scripts/dev/screenshot.py looks at them and a human runs it.

Storage: two controllers, one filesystem

kernel/arch/x86_64/blk.c is the indirection the filesystem goes through; whichever driver comes up binds itself to it, and the order is decided in one readable place in arch_hw.c. fat.c used to call vibeos_x86_64_virtio_blk_read by name from nine places, which was honest while virtio was the only driver and is exactly why nobody noticed the gap.

DriverProvided byGated
virtio-blkQEMUyes, the default smoke run
AHCI/SATAVirtualBox, VMware, real machinesyes, VIBEOS_SMOKE_DISK=ahci

Why AHCI was needed. The kernel could only talk to virtio-blk. The appliances imported into VirtualBox, booted, and then every exec failed: the files were on the disk and there was no way to read them. The bootloader hid it, because UEFI does the reading up to ExitBootServices, and after that there was no block device at all - an absence rather than a failure, which is quieter. CI never caught it because CI only ever ran the controller QEMU defaults to.

The controller is matched by PCI class (01/06/01) rather than by device id: VirtualBox emulates an ICH8, QEMU an ICH9, VMware something else, and a table of ids would be wrong on the next hypervisor. The driver is polled rather than interrupt-driven on purpose - the filesystem calls it from under g_exec_lock, with interrupts masked, so a completion interrupt could never arrive.

The register window is marked uncacheable (vibeos_x86_64_mark_uncacheable). The identity map is built with write-back 2 MiB pages, which is right for memory and wrong for registers whose reads have side effects.

What is verified, and what is only correct. Five sabotage cases were written for this driver; two turn the AHCI boot red (bus mastering left disabled, the port's command engine never started). Three came back NOT RED and are kept in scripts/dev/cases/ahci.txt as a comment: QEMU's ich9-ahci does not enforce the AHCI mode bit, ignores a PRDT byte count that is one too large, and never serves MMIO from a cache line. All three are requirements on real hardware. The only evidence beyond the specification is that the same code also drives VirtualBox's ICH8, which is a second implementation and not a test.

Verified end to end on 2026-08-29: an appliance built by the release pipeline imports into VirtualBox 7.2.16 and boots to the full userland - AHCI port up, FAT16 mounted, INIT.ELF read from disk, all four supervised services exec'd, and the deliberate ring-3 fault killing its task while the machine keeps going.

Pending

  • No NVMe driver. VirtualBox and VMware both offer one, and some machines have nothing else; AHCI covers both hypervisors today.
  • vibeos_storage_scan (partition table then filesystem probe) is built and host-tested but still not wired into boot: the runtime path goes straight to FAT.
  • A gate that looks at pixels. A check somebody has to remember to run is not a check.
  • Input routing. The mouse moves a pointer and nothing else; keystrokes reach the shell rather than a focused window, so the GUI is a drawing rather than a window system.
  • The GUI is kernel code. A ring-3 program cannot query the screen, present a buffer or read input.
  • Driver isolation contract hardening (capabilities, fault containment).
  • Dynamic loading/version negotiation for driver binaries (loader + signature path not yet implemented).
  • Hardware probe lifecycle and hotplug policy.
  • DMA/IOMMU policy, device reset/recovery and driver fault containment for runtime drivers.

Next checkpoint

  • Define a common runtime driver contract and isolate the existing hardware drivers behind it before adding hotplug or additional buses.

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.

Driver Framework (as of 2026-09-29)

In Progress (virtio-blk and AHCI/SATA, virtio-net, PS/2 keyboard and mouse, framebuffer desktop verified; both disk controllers gated in CI). virtio-net transmit is serialised now: it had one staging buffer, one descriptor and a plain 16-bit avail->idx++ shared by every core - the same defect virtio-blk had, never applied here. It presented as a boot wedge with a core spinning on a completion another core had already taken, and QEMU named it on its own stderr: index 65535 available

Program Loading Progress

Status: Rewrite underway (docs/exec/). X-P0, X-P1, X-P3 and X-P4 done. X-P2 mapping on after four attempts; the staging buffers and demand paging remain. Last review: 2026-09-01

Why it is being rewritten

execve is the longest function in arch_hw.c, and everything it does lives there: reading the file, parsing the headers, choosing an address window, mapping segments, placing an interpreter, building the startup block. It holds two static staging buffers totalling six megabytes, reads whole programs into them with interrupts masked, and shares them between every core.

The plan and its phases are in docs/exec/phases.md. This file records what has actually landed.


X-P0 — Say what happens (done, 2026-09-01)

The defect this closes is not a crash. It is that a failed exec could not be diagnosed. The loader had fourteen return -1 sites and one message between them, so every way of failing to start a program produced the same sentence. "Cannot load" is not a diagnosis: a file that is absent, a file that is present and short, and a file that is perfect with no memory left are three unrelated investigations, and nothing in the log distinguished them.

That mattered more than it sounds. A failed FAT lookup once looked like the end of a file, so a flaky sector produced a short image that claimed to be complete and the loader parsed whatever the previous program had left in the shared staging buffer. That defect is fixed - but nothing in the exec path would have said so, because all of those outcomes printed the same line.

What landed

  • include/vibeos/exec_stats.h and kernel/exec/stats.c: eleven refusal reasons, one counter each, portable so a host test can assert them as well as the boot gate. The names are stable text, because the gate matches on them - they are interface, not prose.
  • hw_exec_refuse() in the architecture layer: counts, prints one line naming the reason, the path and the step, and returns -1. One serial_lock call for the whole line, because a message assembled from six serial_puts is six critical sections with three other cores writing into the gaps.
  • Every refusal in hw_proc_create and the not-found refusal above it now goes through it. The two that already had ad-hoc messages of their own are included, since a sentence in the log is not something a gate can assert on - which is exactly how they had stayed outside the tally.
  • exec on the kernel console prints the counters; kernel/exec/view.c holds the formatting, so the architecture layer knows nothing about it.

The path is a parameter, not argv[0]. hw_proc_create takes the path it was given. Re-using argv[0] would have been free and would have been a lie: BusyBox becomes twenty commands by looking up the name it was invoked under. The embedded-image path has no filename at all and passes null, which prints as -.

Gated. The boot gate drives exec and asserts on which reasons appear, not on a total:

[EXEC] loaded=0x1c not-found=0x6

not-found is expected and frequent - a shell resolving a bare command name tries it as a path first, and a C runtime asks for /proc/self/exe, which this filesystem does not have. Every other reason is a failure. That is a much stronger assertion than a count: one no-memory or one short-read hidden among forty expected not-founds is exactly what this watches for, and a total of forty-one would not blink.

The expected set was measured from a green boot, not reasoned about from the loader - the whole reason the phase exists is that nobody could tell which refusals were happening. Emptying the set turns the boot red with exec_unexpected_refusal=not-found, which is how the assertion was confirmed.


X-P1 — The parse comes out (done, 2026-09-01)

A correction to the plan, made in the open. X-P1 said to create kernel/exec/image.c holding a pure segment-layout function. It already exists: kernel/core/elf.c is portable, takes headers and a bias, and produces exactly the list of (vaddr, file offset, filesz, memsz, flags) a loader must satisfy, plus vibeos_elf_page_flags and vibeos_elf_fill_page. Writing a second one would have produced two parsers that must agree about a security boundary, with no build step checking that they do - worse than the long file the phase was meant to shorten.

What was genuinely missing is that none of it was reachable except through a boot. tests/kernel/loader_tests.c fixes that: twelve cases against a hand-built ELF64, so each one names the field it exercises instead of hiding it in an opaque blob.

The layout cases are the ones that have bitten a loader here or are one edit from doing so: two segments with a hole between them (a loader that fills from min_vaddr to end_vaddr hands the program writable pages it never asked for); a .bss tail where memsz exceeds filesz; a page filled with a pattern first, so a filler that copies and forgets to zero cannot pass against a buffer that was already zero; two segments sharing one page, which must carry the permissions of both; an image asking to be placed on top of the kernel; a truncated file, refused rather than parsed as far as it goes; and an ET_DYN image biased exactly once, with image_span proven not to move with the bias.

The startup block is decoded back and compared, not spot-checked. Every field is one a C runtime reads before the program's first line, so a builder that is plausible but wrong kills the program inside the runtime with no reference to the loader that mis-built its stack. Two rules that were comments are now assertions: AT_ENTRY stays the program's entry when an interpreter starts the process - otherwise the interpreter re-enters itself and it looks like a hang - and AT_BASE is omitted for a static program rather than written as zero, because zero is a legal load address and a runtime believes it.

Confirmed by breaking it: changing one expected value turns test_loader red.


X-P2 — attempted, measured, and turned off (2026-09-01)

Status: not done. The mechanism is built and disabled behind if (0), with the measurement that disabled it written next to it.

The step taken was the first half of X-P2: a read-only image page that comes wholly from the file is mapped from the page cache instead of being copied into a fresh frame. The cache already holds the file's page, so the loader was allocating a second frame to memcpy a copy of a copy into.

It works, and the win is large:

[EXEC] loaded=0x1c pages_from_cache=0xfe3 pages_copied=0x19c

4067 of 4479 image pages - 91% - stopped being copied. BusyBox alone is two megabytes of text, exec'd twenty times in a boot.

And it made the machine worse. Eight boots: 6 clean, with two wedges. The parent commit measured 22/24 the same day with no wedge at all. The first run also tripped the stress service:

STRESS_FAIL: the child's own copy-on-write page at offset 0: found 0x5b expected 0x54
STRESS_FAIL: replay with EFI/BOOT/SVC_STRS.ELF 97300336535

Why it is off rather than tuned: there is no mechanism. The failing page is a child's private writable page, which this change never touches - it only maps read-only pages. The likely shape is therefore not the mapping but what it changes underneath: not allocating four thousand frames per boot rearranges every subsequent allocation, so a latent defect that used to land on a harmless frame now lands on a live one. The copy-on-write failure the stress service reports is a known-open defect of roughly one boot in twenty-four, recorded in boot_repeatability.md; this change appears to make it common rather than to create it.

Shipping it in that state would poison every measurement taken afterwards. That is the trap this project has fallen into repeatedly, and it is the reason the numbers above were taken against the parent commit rather than read on their own.

What is kept, because it is sound on its own:

  • vibeos_elf_page_file_offset - the pure function deciding which pages could ever be shared, with five host cases. It is mostly about saying no: a page straddling the end of filesz, a page inside the .bss, a page covered by two segments, a page in a hole, and a segment that does not start on a page boundary. Every wrong "yes" maps file bytes where zeroes belong, and a program whose .bss starts out holding whatever followed .data in the file runs - it just runs wrong, much later.
  • pages_from_cache and pages_copied on the exec line, so the ratio is measurable rather than argued about.
  • The refusal list on that line now has a refused: marker. Adding two counters silently broke the gate's bare name=0x... pattern, which would have read pages_from_cache=0x.. as a refusal called "cache". A marker costs six characters and makes the boundary a fact rather than a coincidence.

Measured a second time, after the blocker was closed (2026-09-01)

The copy-on-write exclusivity window - the defect this was thought to be exposing - was found and fixed the same day, from CI logs. So this was turned back on and measured again.

The wedges are gone. That half of the first measurement was the copy-on-write defect, and closing it removed the silence entirely: with this branch on, no boot goes quiet any more.

It is still off. 21/24 with it on against 23/24 with it off, same tree, same day. Three failures against one proves very little on its own - and this file already records what happens when a count is treated as a verdict. What decided it is what failed: all three were segmentation faults in Linux binaries at near-null addresses, in a different program each time (BusyBox twice at an identical rip, musl once), and that shape is absent from the runs with this off. A different program each time is this project's oldest signature for memory corruption.

The narrowed suspicion, which is the useful output of the second attempt: this branch introduces a second source of truth for which file is being loaded. The bytes come from g_exec_elf, filled by hw_read_file_cached(path); the pages come from the cache under hw_file_id(path) computed again, separately. Anything that makes those two disagree - a fallback read, a path truncated into hw_file_id's fixed-size buffer, a full identity table - hands a process the pages of one file with the headers of another. The fix is not to check harder at the mapping site but to make the read report the identity it actually used, which is X-P2's real shape anyway.

One thread worth pulling: one of the three faulted at address 0xe4, and the wedge chased for a day in boot_repeatability.md restored a register context full of 0xe4. That may be one corruption family rather than two mysteries.

Attempt three, and what it eliminated (2026-09-01)

The hypothesis from attempt two was that this branch created a second source of truth for which file is being loaded. It was implemented properly: hw_read_file_cached now reports the cache identity the bytes actually came from - 0 when it fell back to an uncached read - and that identity is threaded to the mapping site instead of hw_file_id(path) being recomputed there. That is a real improvement and it is kept: the ratio moved to 4242 pages mapped against 237 copied, from 4067/412, which is exactly what a more often-correct identity looks like.

The failure did not move: 21/24 again. So the hypothesis was wrong.

Two further explanations were then eliminated by measurement rather than by reading code:

  • Wrong content at map time. A probe compared every page mapped from the cache against what vibeos_elf_fill_page would have built for it. Zero mismatches across a whole boot.
  • Stray writes to a page that is now shared. hw_cache_audit compares every resident cache page against the file it came from, at the end of the boot. 1820 checked, 0 changed. That detector is kept and gated: once image pages are shared rather than copied, one frame is the text of every process running that program, so a single stray write reaches all of them - and reaches them as a program misbehaving far from whatever did the writing.

One more fact worth more than the count: two of the three failures were byte-identical - BusyBox, the same rip, the same fault_addr=0xe4. This is a deterministic defect reached by a specific path, not a race. That is a much better starting point than any of the three attempts began with.

So the bytes are right when they are mapped, right at the end, and both sides agree which file they came from. What is left is the mapping itself - aliasing, a reference, or a lifetime - and that is where a fourth attempt should start rather than at the top.

It was the page cache having no lock (2026-09-01)

kernel/mm/backing.c had no lock. Not a weak one, not a wrong one - none, in a table with linear probing and a clock hand.

It was invisible for as long as the cache had exactly one caller. hw_read_file_cached runs under g_exec_lock, so the table was serialised by accident. The moment this branch added a second caller - a loader mapping image pages, reachable from a boot-time spawn that holds no such lock - the table became concurrent and nothing said so.

Two cores placing entries at once can leave one entry's frame beside another entry's key. A lookup then hits and returns the pages of a different file. From outside that does not look like a cache defect at all: it looks like a program that was handed somebody else's text - which is exactly the shape of the two byte-identical BusyBox faults.

It is the fourth time this project has needed a set_lock, and the third layer to have shipped without one. The pattern is now the same in all three.

Two decisions inside the fix:

  • A lock of its own, not the frame layer's. A lookup that misses allocates a frame, and the frame layer takes g_mm_lock to do it, so sharing the lock would deadlock on the first miss. Ordering is cache then frame, never the reverse; nothing in the frame layer knows the cache exists.
  • The disk read stays inside the lock. It serialises the execs that miss. Taking the lock only around the table would leave a window where a slot is claimed and not yet filled, and the comment already in that function says what a hit on such a slot hands out.

Measured: 22/24 with the mapping on, and the signature is gone. Zero occurrences of the faulting rip that appeared in both earlier runs, zero unexpected CPU faults. The one failure is a wedge in busybox_cat with no crash record - the background failure that appears with the mapping off as well.

The full picture, and the reason the count alone was never the argument:

statebootsthe deterministic signature
mapping off24/24, 23/24, 22/24absent
mapping on, cache unlocked21/24, 21/24present, byte-identical twice
mapping on, cache locked22/24absent

22/24 sits inside the off baseline, which is 22 to 24 - a range I had earlier described as "24/24", and that overstatement is corrected here. What actually separated the two states was never the count; it was whether a specific deterministic fault appeared. It no longer does.

Status: off again (2026-09-02), and the claim above was wrong.

Four failures in twelve boots came back with fault_addr=0xe4 at rip=0x57da77 - the same signature, in three different programs. It had never gone away.

The error is worth naming precisely, because it is not a subtle one. The "22/24 with the signature gone" claim rested on grepping that rip in the failing boot of that run - and that boot was a wedge, with no crash record at all, so the rip could not have been there. An absence was read out of a log where it could not appear, and treated as evidence.

With the mapping off one run gave 12/12 with neither marker, and that was read as a clean differential. It was not. The same fault appeared later in the session with the mapping still off.

Twelve boots without a defect that shows up a few times in twenty-four is a quiet run, not evidence. Three different conclusions have now been drawn about this one signature from too few boots - that it was gone, that the mapping caused it, and that turning the mapping off removed it - and all three were the same mistake.

The branch is still off, and now for a weaker and more honest reason: it has never been shown to be safe, rather than having been shown to be the cause.

What is now known about it, after four attempts: the bytes are correct when mapped, correct at the end of the boot, both sides agree which file they came from, and the page cache is locked. The fault is deterministic - same rip, same address - so it is reached by a specific path rather than won by a race. The remaining suspects are all in the mapping itself: aliasing, a reference, or a lifetime.


X-P3 — All-or-nothing (done, 2026-09-01)

A failed exec leaked an entire address space. hw_proc_create had eleven refusal points after the address space existed, most of them after pages were mapped and regions recorded, and every one returned -1 and walked away. execve then reported ENOMEM, the calling program carried on correctly - and the page tables, every frame they mapped and every region descriptor stayed allocated with nothing holding a name for them.

The unwinding is now structural rather than remembered: all eleven route through one fail: label that destroys the address space, clears the region list and zeroes the fields it filled. There is one function that builds a process, so there is one function that takes it apart, and a refusal added later cannot forget to.


X-P4 — Acceptance (done, 2026-09-01)

The interpreter substitution is one function, and the build enforces it. (Superseded 2026-09-30 by docs/abi/ A4: the substitution and check-exec-layering.sh are deleted, and the loader is opened at the path PT_INTERP names. Kept as the record of why the stand-in stayed findable.) hw_interp_path_substitute is the only place that may name an interpreter path; scripts/dev/check-exec-layering.sh fails if a second one appears anywhere in the kernel, or if the function stops saying in its own comment that it is a stand-in. The risk with a stand-in is not that it exists - it is that it breeds, the two copies disagree, and it stops being something anyone can find or delete.

Confirmed by breaking it: a hard-coded path added to an unrelated file is found and named. It also caught a real mistake within the hour - a new function inserted between the substitution's comment and its signature, splitting a comment from what it describes.

Re-exec is cheap, asserted as a ratio. pages_from_cache and pages_copied are on the exec line, and the gate asserts the former exceeds the latter when the mapping is on. A threshold in pages would be tuned to today's manifest and would fail the day somebody adds a program.

A truncated program is refused, not parsed. Covered at the parser by case_truncated_is_refused_not_parsed in the loader tests, and at the loader by VIBEOS_EXEC_SHORT_READ having a counter the gate watches.

Concurrent exec is serialised, not parallel, and that is recorded rather than claimed. g_exec_lock makes two execs take turns, because they share one staging buffer. The acceptance criterion as written - two processes exec'ing different programs at once - is satisfied in the sense that neither corrupts the other, and not in the sense of them proceeding together. That only becomes true when the staging buffers go, which is the part of X-P2 still open.


**What X-P2 still needs, in order: the copy-on-write defect closed first, since it is what makes this unmeasurable; then demand paging, so execve stops reading whole files; then g_exec_elf and g_interp_elf and their six megabytes go.

One thing this phase cost, worth recording

The first version of the gate change had a syntax error - a new block inserted between an if and its else. check.sh runs the gate as python3 ... > /dev/null 2>&1 and then reads qemu-cli-summary.txt, so the crashed gate left the previous run's summary in place and check.sh reported reason=cli_and_network_verified. Three runs passed having executed nothing.

It is the same trap as "a green build is not a build", wearing different clothes, and the tell was the same: a file's timestamp that had not moved.

Status: closed, with two gaps named

X-P0 (refusals that say why), X-P1 (one unwinding path, one place that names an interpreter) and the exec-stats view are done and gated. The plan is closed rather than finished, and what it does not cover is written down here rather than left to be rediscovered:

  • X-P2 is off. Mapping image pages from the page cache is present and disabled at if (0 && file_id != 0u ...). It was measured against a baseline that was itself unstable, and what argued against it was not the count but a signature: BusyBox, the same rip, the same fault address, byte-identical twice, present with the change and absent without it. Demand paging and the deletion of the 6 MiB staging buffers depend on it and are not started.
  • Threads and exec together are not run in the boot. musl_texec passes its own rounds and the gate asserts on it whenever it is started, but running it in the boot script wedges a later phase. See sched_quantum_threads.md.

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.

Program Loading (as of 2026-09-29)

Rewrite underway (docs/exec/). X-P0, X-P1, X-P3 and X-P4 done: every exec refusal names itself and is counted, the segment layout and startup block are host-tested (twelve cases), a failed exec no longer leaks the address space it had already built - eleven refusal points route through one unwinding path - and the interpreter substitution is one function the build checks for. X-P2 mapping on: image pages come from the page cache, 4242 mapped against 237 copied. It took four attempts; the cause was that kernel/mm/backing.c had no lock - invisible while the cache had one caller under the exec lock, and a corruption the moment the loader became a second one. Fourth layer in this project to need a set_lock. The staging buffers and demand paging are still open

The execve failures that said nothing

Status: closed. Every exec refusal is now named, counted and asserted.

How it surfaced

A six-boot run came back 4/6, with both failures reporting stress_seed_not_reported,stress_run_did_not_finish. Every memory counter was clean — no leaks, no double allocations, no poison hits, no rmap mismatch — and SVC_STRS.ELF appeared in the log only as a line of ls output. The service had never run.

Following it took three steps and none of them should have been necessary:

  1. init printed SVC_START svc-stress 10, so the fork succeeded.
  2. The scheduler printed task pid=0xa exited code=0x7f — 127.
  3. Reading user/prog/init.c showed where 127 comes from: the child calls execve and, when it returns, exits 127. execve had failed.

And the kernel said nothing about it. The [EXEC] … refused: tally showed not-found=6, the same six a healthy boot shows.

What was wrong

hw_exec_refuse prints a line and increments a per-reason tally, and the boot gate asserts the set of reasons seen contains only not-found — which is a much stronger check than a count, and was already right.

Three paths never reached it. The copies from user memory — the path string, the argv vector, the envp vector — returned a bare -EFAULT, -E2BIG or -EINVAL. No line, no tally, no reason. An execve that failed on one of them was indistinguishable, from outside, from an execve that was never called.

That is the same defect this subsystem already carried a comment about, in the place where it had been fixed:

Counted as well as printed. This one had a message of its own, which is exactly how it stayed outside the tally: a sentence in the log is not a reason anything can assert on.

The not-found path was fixed with that reasoning. The three beside it were not, and nothing noticed for as long as they never fired.

The fix

A new reason, VIBEOS_EXEC_BAD_ARGS — "the kernel could not read the path, the argv vector or a string in it" — and the three paths routed through hw_exec_refuse, with a detail saying which vector: a program with too many arguments and a program handed a bad environment pointer are not the same bug.

Confirmed by breaking it. Setting VIBEOS_HW_MAX_ARGV to 1 makes init's two-entry argv too large:

[EXEC] refused reason=bad-args path=EFI/BOOT/SELFTEST.ELF at=argv
[EXEC] refused reason=bad-args path=EFI/BOOT/SVC_OK.ELF at=argv
reason=missing:VIBEOS_SELFTEST_DONE verdict=guest_wedged

The boot goes red and says why. Before this change the identical sabotage produced services that quietly did not start.

A note on that sabotage, because it nearly passed for a different reason: the first attempt used sed against 32u while the constant reads 16, so the edit never applied and the run came back green having proved nothing. It was caught only because the script prints the constant before and after. CLAUDE.md's rule about a sabotage run that proves nothing is worth applying to the edit itself, not only to the harness.

It named itself on the first occurrence

The eight-boot run after the fix hit it once, and the failure that used to read

reason=invariant_failed:stress_seed_not_reported,stress_run_did_not_finish

now reads

reason=invariant_failed:exec_unexpected_refusal=bad-args,stress_seed_not_reported,...
[EXEC] refused reason=bad-args path=EFI/BOOT/SVC_STRS.ELF at=argv

with the chain complete and legible in three lines:

SVC_START svc-stress 10
[EXEC] refused reason=bad-args path=EFI/BOOT/SVC_STRS.ELF at=argv
SVC_EXIT svc-stress 10 127

Two things narrow it a long way. The path copied fine - it is in the message - so the failure is specific to the argv vector, not to reading user memory in general. And svc_argv lives in init's .bss: the child of the fork writes argv[0] and argv[1] into it, which is a copy-on-write write, and execve then reads that same page back.

So the shape is a page the child has just faulted copy-on-write, read by the kernel a moment later.

What is still open

Which of the range check's reasons applies. "bad-args" says a vector could not be read and not why, and the check has a why for exactly this - "not accessible without a reason is a dead end" is already written down beside it. The refusal now carries it: the detail reads argv:pdpt_absent_or_not_user or argv:leaf_not_writable or whichever level of the walk failed, because an absent PML4 entry and a leaf that is present but not user-accessible are completely different bugs.

That distinction is what decides between a stale TLB entry after the copy-on-write fault and a page-table level that was never installed. The next occurrence answers it.

The external reviews, checked against the code

Three reviews so far. Every finding was verified before being accepted or dismissed, because a review is a detector and the first question about a detector is whether it is right.

Review two

The result, and the thing that reframes half of it

IDClaimVerdict
M-001vmspace_protect modifies PTEs without CASReal, live — fixed in ab5d7f4
S-001lazy init of the syscall waitset races on SMPReal pattern, unreachable code
S-002the security audit log is unprotectedReal pattern, unreachable code
I-001interrupt counters are not SMP-safeReal pattern, unreachable code
M-002clone_cow is not transactionalReal, live, benign
M-003rmap bookkeeping failures are ignoredReal, but the divergence is counted and gated
C-001locking discipline is not uniformArchitectural; the core plan's subject
F-001filesystem global state needs an SMP auditNot examined here

Three of the four concurrency findings are in code no process reaches.

  • vibeos_syscall_dispatch is called from tests/kernel/kernel_tests.c and nowhere else. The arch layer never names it.
  • vibeos_sec_audit_record is called from kernel/core/syscall.c — the same unreachable dispatcher — and from the tests. Grepping the arch layer for vibeos_sec_* returns nothing at all: the running kernel never calls the security layer.
  • kernel/core/interrupts.c is named by no file outside kernel/core.

That is the third independent confirmation of the same structural fact. check-reachable.py reports it mechanically, docs/core/ states it as C3's subject, and now an outside reviewer has found three more defects in that half without knowing that nothing runs it.

It does not make them harmless — C3's gate is precisely that nothing there may become reachable from ring 3 before its defects are closed, and that list has grown from three to six. It changes what to do about them: this is evidence for deleting that half rather than for hardening it, and C3 says the choice is made by asking which produces the smaller diff in C4.

Where the review's mechanism was wrong

Two findings are real and the stated mechanism is not the one in the code. Both times the real defect was worse, which is worth recording: a review that is approximately right is more useful than one that is vaguely right, and the difference only appears by reading the code.

M-002. The review says a failed clone leaves the caller to clean up. The caller does clean up: hw_sys_fork calls hw_aspace_destroy(&child->proc.as) on failure, releasing the references the partial mappings took. What survives is that the parent's entries already converted writable→COW stay converted — and that is harmless. A COW-marked page is correct; the parent takes a fault and copies. Rolling it back would not even be safe, because another core may already have faulted on the converted entry. Documented, not "fixed".

M-003. The review says ignoring rmap_add/rmap_remove failures makes the reference count and the real mapping count diverge silently. They do diverge — but not silently: rmap_exhausted and rmap_missing_remove are both on the [MEM] MUSTBEZERO line the boot gate asserts, and add_locked increments exhausted before returning −1. What is true is that the call sites proceed rather than rolling back, which this subsystem chooses deliberately and the counter is what makes safe.

One genuine hole the review did not find: rmap_add also fails when frame_index(phys) == RMAP_NONE — a frame outside the pool — and that branch increments nothing. A mapping of such a frame is silently untracked, so the "owners == rmap holders" invariant quietly does not apply to it and no number says how often.

The deterministic test, and the three arrangements it took

Yesterday's commit owed a test: the H-001 and H-002 fixes had no way to fail. It exists now, driving fork's clone through an injection point (a harness cannot otherwise produce a window a few instructions wide).

Getting it to fail took three attempts, and the first two were the shape this project has recorded five times — right about the property, wrong about the arrangement:

  1. The hook fired before clone_one loaded the entry, and the test re-read it. The compare-exchange therefore never lost, and the sabotaged kernel behaved exactly like the fixed one.
  2. The hook wrote the same value back. The page was already writable with no copy-on-write mark, so there was nothing for the exchange to lose against.
  3. The hook installs a different frame — which is what the fault's copy path actually does. Now the exchange loses and the two kernels separate.
sabotage (clone_one adopts again):
    FAIL:fork left the parent writable on a shared frame
fix restored:
    ALL_TESTS_PASS

Without the sabotage this would have shipped green in both states, and yesterday's fix would have stayed "verified" by something that could not tell the difference.

Review three

Four findings, in a table titled "verified and reachable". All four are real. None of them is reachable.

IDClaimVerdict
VOS-001THREAD_CREATE has no ownership checkReal — and already in docs/core/README.md
VOS-002process slots leak after terminateReal — also already there
VOS-003the global waitset is unsynchronisedReal — the same as review two's S-001
VOS-004kernel/mm/vm.c is a model, not page tablesCorrect, and it classifies itself correctly
vibeos_syscall_dispatch   called from the arch layer: 0
vibeos_thread_create                                : 0
vibeos_proc_terminate                               : 0
vibeos_vm_map / vibeos_vm_create                    : 0

One case needed care rather than a grep. kmain.c is entered — it prints BOOT_OK — and it calls vibeos_proc_terminate. But that call sits inside vibeos_kernel_dispatch_trap, and the arch layer never calls that, so the path stays dead. "The file runs" and "this function in it runs" are different questions.

VOS-001 and VOS-002 are word for word two of the three defects the core plan has listed for weeks. An independent reviewer rediscovering them is genuine confirmation that they exist. Labelling them reachable is the fourth independent misreading of the same structural fact.

What that produced, which is worth more than the findings

Three reviews in a row read kernel/core/syscall.c and kernel/proc/process.c as live kernel code and rated defects in them HIGH. check-reachable.py knew, docs/core/ said so, and a person opening the file had no way to find out.

Those four files now open with a banner saying they are not reached, carrying the command that verifies it rather than asking to be believed, and carrying the reason it is not an invitation to relax:

The danger this banner guards against is the opposite of complacency — it is somebody wiring this up because it looks finished.

C3's gate is unchanged and its list has grown from three defects to six.

And a defect in the checks, found while clearing the banners

The banners are comments, so they cannot change behaviour — proved rather than asserted, by comparing the compiled .text with and without them: byte identical.

What they did do was raise four -Wcomment warnings, because the banner contained kernel/arch/x86_64/*.c and that is a /* inside a block comment. Those warnings were dismissed as "transient" three times in one session.

The reason they could be is the real finding. warnings= filtered only build-id, while clang-warnings= also filtered unused-command-line-argument - which clang emits eight times on every clean build, assembling entry.s with C flags. So the gcc-side counter read 8 on every clean build, always, and a counter that is never zero is a counter nobody reads. The two counters disagreed and the noisier one is the one seen first.

Both filters now match, and a clean build reads warnings=0. CLAUDE.md already says to treat a moving warning count the way rc= is treated; that only works if the count is telling the truth.

Review four

One finding, and the sharpest of the set: vibeos_vmspace_map_raw ignores the return of vibeos_rmap_add, the node pool is finite, so a mapping can be published with its holder unrecorded. Reachable by creating enough concurrent mappings.

All of that is true. The review then asked the question the previous three did not: what depends on the map being complete?

The answer is nothing, and for a reason that was written down nowhere. Every consumer cross-checks against vibeos_frame_owners, which is taken by the mapping itself and knows nothing about the node pool:

anon.c:59       swap-out    owners != 1 || rmap_count != 1  -> skip
vmspace.c:1126  swap-out    the same pair                   -> refuse
vmspace.c:1257  compaction  owners != rmap_count            -> refuse

So an under-recorded holder makes the two disagree and the operation refuses. Reclaim degrades; nothing is corrupted. exhausted, untracked and nodes_peak say how much. The pool is sized at two nodes per frame — about 208,000 against an observed peak of 1,709 — so exhaustion needs a far more forked workload than anything here produces.

Trusting rmap_count alone is what turns that into corruption: swap-out would evict a frame a second address space still maps, and compaction would move one and leave the other mapping pointing at the old address.

That property is stated in include/vibeos/rmap.h now and enforced by check-rmap-crosscheck.py, validated by removing the cross-check in anon.c and watching it go red.

The check was wrong on its first run, and the fix was not to widen it

It flagged vmspace.c:1237, total = vibeos_rmap_count(old_phys). That read decides nothing — it bounds an array, and an under-recorded count makes the bound more permissive while the real cross-check twenty lines later still refuses. A false positive.

Widening the window to reach the guard would have swallowed the property: a bare use twenty lines from a cross-check on a different frame would then pass. So the site carries a rmap-bare-ok: marker with prose saying why instead. A second bare use is not forbidden — it is noticed, and somebody has to write down why it is safe, which is the trade check-chokepoints.py already makes.

And the branch none of the four reviews found

vibeos_rmap_add has two failure modes and only one was counted. Pool exhaustion increments exhausted. A frame outside the region this layer describes returned −1 in silence, so that mapping went unrecorded and no number said how often. It increments untracked now.

It reads zero, and that zero is qualified rather than banked: every frame this machine maps lies inside the region, so the branch is genuinely not taken. What does not exist yet is a run in which it is deliberately non-zero, so it is weaker evidence than the counters beside it — which is the distinction this project keeps between a counter that reports nothing happening and one that nothing increments.

Filesystem Layer Progress

Status: In Progress (five filesystems behind one VFS; journalled writes verified against power loss in a host test with a modelled drive cache, which is not the same claim as any machine having survived it; the kernel's own root is still FAT) Last review: 2026-08-26

Shape of the layer

Four levels, each testable on the host without a kernel, which is where their bugs are actually findable:

  1. Block device (include/vibeos/blockdev.h) - a pair of functions plus a flush. Nothing above knows whether it is virtio; nothing inside knows what the bytes mean.
  2. Block cache (kernel/fs/blockcache.c) - fixed slots, write-back. The only barrier that exists is a flush, and that flush now empties the device's cache too. Without it the ordering everything above believes in is fiction, because a drive acknowledges a write when it reaches its own volatile cache.
  3. Filesystem drivers - one vibeos_fs_ops_t each.
  4. Mount (kernel/fs/vfs.c) - lookup, read_at, write_file, list, unlink, mkdir, with no filesystem named above it.

Partition tables (MBR and GPT) sit between the device and the drivers in kernel/fs/partition.c.

Implemented

FilesystemAccessNotes
FAT16/FAT32read/writethe boot volume (kernel/fs/fat.c). Since docs/abi/ L1 step 4 it writes in place: offsets, truncate, long names created, rename, rmdir, growing directories, statfs. Checked against mtools and scripts/dev/fat-fsck.py, on the host and on the image each boot leaves
tmpfsread/write/tmp, in memory (docs/abi/ L1 step 2): modes, owners, links, sparse files
ext2readdirect, indirect, double and triple indirect blocks; holes
ISO9660readincluding Joliet names
exFATread
NTFSreadfixups, resident and non-resident attributes, run lists
  • Journal (kernel/fs/journal.c): write-ahead, one transaction in flight, recovered at attach. A transaction lands entirely or not at all.
  • The syscall layer no longer names FAT: it goes through vibeos_fs_*.
  • VFS runtime/service scaffold in user/fs/vfs_service.c and user/fs/vfs_ops.c, with the policy-aware secure open path.
  • x86_64 getdents64 now rejects non-directory descriptors and bounds each directory stream to 4096 entries, preventing malformed or cyclic metadata from wedging a user process indefinitely.
  • The QEMU CLI harness records phase-specific BusyBox progress, and filesystem changes are covered by a dedicated Clang Release runtime workflow with serial/stderr artifacts. The Linux/WSL gate still must pass before this area can be marked runtime-complete.

How it is verified

Host tests, plus a sabotage case per guard: each one removes a single check and must turn a named test red. Cases live in scripts/dev/cases/, run with scripts/dev/sabotage.py. Several tests in this project have passed while proving nothing, so a guard without a red case is not considered covered.

The durability claim is tested as the claim itself, not as its mechanism: a fake drive with a volatile cache of its own, told to stop accepting writes after exactly N of them, swept across every N a transaction performs and across several flush orderings. The volume must come back holding either the old contents or the new ones. That sweep is what found two real defects - a sequence number that cannot identify a commit record because the counter restarts at every mount, and a magic word that is load-bearing because the region is reused by transactions of different lengths.

Pending

  • The kernel mounts FAT as its root; the other four drivers are reachable through the VFS but nothing boots from them yet.
  • Writing on a disk is FAT-only. The journal exists and is tested, but no filesystem driver routes its metadata updates through it yet - that connection is the next piece of work, and until it is made "crash-safe" describes the journal and not the volume.
  • ext2/ISO9660/exFAT/NTFS are read-only by design for now.

Next checkpoint

  • Route FAT metadata updates through the journal, then re-run the power-cut sweep against a real FAT volume rather than a synthetic target set.

What runs, and what only compiles

Written while planning the I/O refactor, from a survey rather than from the file names — and it corrects a claim this file's own table row was making.

Only FAT is mounted on a booting machine. ext2, NTFS, ISO9660, exFAT, the journal, the partition reader and the block cache are host-tested and have never touched a real disk. That is about 2400 lines with no runtime evidence, and this project already has the lesson written down from the AHCI gap: the driver nobody runs is the one that ships broken.

FAT bypasses the block cache. It reads sectors directly through vibeos_x86_64_blk_read into two static buffers of its own, so the only filesystem that runs is the one that does not use the only cache there is. There are three parallel read paths and they share nothing.

Writes reach the medium and nothing checks them. This entry first said nothing writes at all. That was wrong, and the counter added in I1 disproved it within minutes of existing: a boot performs thirty sector writes, because the shell's mkdir DOCS reaches vibeos_fs_mkdir, which reaches FAT, which rewrites both copies of the table.

The accurate statement is narrower and still worth acting on: nothing verifies that what was written is what comes back - no read-back, no comparison, and nothing at all across a reboot, which is the only check that separates a write that reached the disk from one that reached a cache.

The journal's power-loss recovery is verified in a host test, not by a machine that lost power. That claim is true and is not about the running kernel, which is the distinction the table row was losing.

An error has no reason. blk_read returns int. A timeout, an absent device, a medium error and a request never issued are indistinguishable at every layer above.

There are no I/O counters at all. Not few - none.

The plan that addresses all of this is docs/io/.

H-012: a FAT cluster from the disk could name any sector of the device (fixed 2026-09-11)

fat_cluster_lba computed data_lba + (cluster - 2) * sectors_per_cluster in 32 bits for whatever cluster it was given, and only the chain walk in fat_next_cluster checked a cluster against the volume. A file's first cluster, and a subdirectory's, came straight from a directory entry. The block layer bounds a request by the whole device, not by the partition - the boot sector's total-sectors field was a local of the mount and never stored - so a crafted entry reached sectors outside the volume, by offset or by 32-bit wrap.

The review reported the read. The write is the worse half: a parent directory's cluster comes from fat_resolve unchecked, fat_dir_sector turns it into a sector, and creating a file, unlinking one or making a directory reads that sector and writes it back with one 32-byte entry changed. A crafted subdirectory turned "create a file here" into a write to another partition. File data writes were never affected: they go to clusters just allocated from the FAT.

The translation now lives in kernel/fs/fat_chain.c as vibeos_fat_cluster_sector, where the host tests reach it. It refuses a cluster outside 2 .. max_clusters + 1 before any arithmetic, computes the sector in 64 bits, and refuses a cluster whose sectors are not inside the partition, which the mount now records. fat_cluster_lba returns 0 for a refused cluster - sector 0 is never a data sector, because the reserved sectors come first - and sets the chain error; the read, list and directory-sector paths, both write paths and the extent query stop on it, and vibeos_fat_chain_read treats a sector of 0 as a refusal.

Red first, with a caveat about what red proves here. The driver is not built into the host tests, so the arithmetic was first moved into the new function unchanged and test_fat_cluster_sector_bounds written against it: it failed on today's arithmetic and passes with the checks. It covers the last valid cluster, one past it, clusters 0 and 1, the reviewer's far cluster, a 32-bit wrap on its own and the partition bound on its own. That the driver's paths stop on a refused cluster follows from the calls changed, not from a test that drives them - a crafted FAT image in the boot gate would, and it does not exist.

Host tests green, check.sh all green, twelve boots: 12 pass, 0 fail - the machine boots from a FAT volume through the changed path.

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.

Filesystem Layer (as of 2026-09-29)

Refactor underway (docs/io/). I0-I5 and I4b, I4c done; I5b done; I5c closed on its terms (the journal is over-determined against single faults, so a fourth torture would need two at once - written up rather than built); I6 done for virtio-blk - the queue and the completion contract exist and are sabotage-checked, and the disk now wakes the waiting core instead of being polled: about twenty thousand interrupts a boot ending some sixty per cent of the waits, asserted by the gate. AHCI too, taken second as a port rather than an investigation once the path was proved - and it turned out to have the same two defects, Interrupt Disable left as the firmware set it and the port interrupt-enable register never written. I6 is done; I7 nearly done - a disk that fails on demand, swept across which request fails and how, with the block layer three promises checked together (told it failed, a reason, nothing in flight), plus a transfer that fails after moving half the data, a barrier the device refuses, and the layer re-entered while a request is in flight. What stays open is genuine concurrency: two cores inside the layer at once needs the boot, not a host test. A boot mounts six volumes across five filesystem implementations - two FAT, plus ext2, ISO9660, NTFS and exFAT read from images built by the host own mkfs tools. Every sector goes through one block cache; the block layer bounds every request and gives a failure a reason; writes are proved within a boot against an offset-carrying pattern; the disk reports its own partition table, and can now write one, MBR or GPT. The kernel has a log on a second disk that survives a reset. Four of the five filesystem drivers had never parsed a byte before this phase, and three of them were broken in ways only a real image could show - which is what the phase was for. The AHCI job of the Filesystem Runtime Gate had been red for days and is green now: "the disk" meant whichever driver bound first, which is the boot disk only when there is one disk - I5b added a second, deliberately on the other controller, and with the ESP on AHCI adapter 0 is a blank 4 MiB file. The boot did one read, one sector, every exec said not-found, every task exited 127, and every must-be-zero counter in the block layer read zero and was right: the read succeeded, it just was not a filesystem. A machine that reads the wrong disk perfectly has nothing to report. The same false invariant was encoded twice, one file apart - the log sink hardcoded adapter 1 as "not the boot disk" and so wrote over the ESP. The boot disk is now whichever carries a mountable volume, both controllers pass, and the sabotage reproduces the CI signature exactly (io_boot_disk.md). Open: nothing yet checks that a write survives a reboot, and the wedge rate rose with the last two images - catching one found a core executing the free-page poison on a frame that is another core CR3, which is a memory-lifetime defect rather than a filesystem one.

Init System Progress

Status: In Progress (native ring-3 init is PID 1 and supervises a manifest of four services in the guest - start, clean stop, bounded restart, and a real crash - all gated; dependency ordering and backoff remain a host-tested model) Last review: 2026-08-29

Implemented

  • Init service bootstrap stub in user/init/init_system.c.

  • Service startup orchestration integrated with service manager.

  • Start/stop lifecycle controls for core user-space services.

  • Dependency-aware init graph execution (vibeos_init_graph_start) with explicit unresolved-node reporting.

  • Restart-class policy controls for core vs optional services (vibeos_init_restart_policy, vibeos_init_restart_note, vibeos_init_restart_allowed).

  • Dependency resolution now evaluates only enabled nodes; disabled optional services no longer make an otherwise valid startup graph fail. Regression coverage verifies the enabled/failed counts.

  • Service-manager startup now commits the manager to RUNNING only after all dependencies start; each failure path rolls back already-started services and resets the supervised count.

  • Native service manifest and runtime snapshot contracts are defined in include/vibeos/userland.h and validated host-side.

  • user/servicemgr/supervisor.c provides a bounded runtime supervisor model with dependency ordering, STARTING/RUNNING/FAILED states, restart limits and exponential backoff. The model is covered by the kernel host regression suite.

  • The native ring-3 init is PID 1. user/prog/init.c is what the kernel loads from the boot volume as EFI/BOOT/INIT.ELF; it forks, execs the bring-up workload as a child (EFI/BOOT/SELFTEST.ELF), reports the pid it is now responsible for, and waits for it. Gated on NATIVE_INIT_READY and on NATIVE_INIT_CHILD_PID= - the second because an init that exec'd the workload in place would produce an otherwise identical boot, so without it the gate would prove only that init ran and nothing about what it is for.

    The workload is supervised rather than replaced on purpose: it was PID 1, which is why there had been no process left over to supervise anything. Making init its parent moved the boundary without trading away any of the coverage that already existed.

  • Media generation now exposes an explicit VIBEOS_USE_NATIVE_INIT=ON switch to select that candidate as INIT.ELF; the default remains the legacy bring-up workload until the native-init QEMU acceptance gate is green.

  • Supervisor startup now fails closed when dependency resolution stops before all manifest nodes are running, including cyclic dependency graphs; host regression coverage verifies the bounded failure.

  • Supervisor lifecycle now distinguishes clean STOPPED exits from FAILED faults, applies restart policy accordingly and marks a service failed only after its bounded restart limit is exhausted.

  • Supervisor runtime snapshots now support explicit service-to-PID binding, reverse lookup and unbind with collision rejection; this is host-verified groundwork for process-backed init supervision.

  • Exit events can now be reported by PID (vibeos_service_supervisor_report_exit_pid), atomically unbinding the process and applying service restart policy. Supervisor clock ticks remain successful while exposing terminal FAILED state through health snapshots.

  • The x86_64 bring-up now initializes a bounded native supervisor manifest and binds the initial runtime task to the init service PID; task exit reports are forwarded through the PID-based API. Automatic process restart and QEMU acceptance remain pending.

  • Supervisor adapters may now provide bounded spawn/stop hooks. After a fault, the replacement PID is bound only after the spawn hook succeeds, then the service returns to RUNNING; host tests cover the complete PID replacement transition.

Runtime supervision (verified in the guest, not only on the host)

user/prog/init.c runs a manifest of four services and applies each one's own policy to whatever exits. The boot gate asserts the behaviour rather than the log markers, and every assertion below has a sabotage case in scripts/dev/cases/services.txt that was confirmed to turn it red:

ServiceWhat it doesWhat it proves
selftestthe bring-up workload; ends by exec'ing the shella session, not a daemon: whatever status it leaves with ends the run and is not a fault
svc-okexits 0a clean stop is left stopped. A supervisor that restarts everything produces a busy, plausible log and an infinite loop
svc-flapexits 3, every timerestarts are applied and bounded: twice, then FAILED and left alone
svc-crashdereferences nullthe kernel kills the faulting task and the machine keeps running

svc-crash is the one that changed the kernel. Every other service dies by exiting, which is a cooperative death that never reaches the trap handler, so "the kernel stays up after a service crashes" was gated, green, and false: a ring-3 fault panicked the machine, under a comment saying there were no user processes on metal. That had been true once. One null dereference - or one hlt - from any unprivileged program halted VibeOS.

It was found from the other end. A boot wedged in the BusyBox phase; the trap dump named a #GP at a ring-3 address, which turned out to be the hlt inside musl's __stack_chk_fail stub in a test binary. The stack-check failure itself is still open (see Pending); what it exposed was that the kernel's response to any ring-3 fault was to stop.

A ring-3 fault now kills the task with the signal Linux reports for that vector (#PF and #GP to SIGSEGV, #UD to SIGILL, #DE to SIGFPE). A kernel-mode fault is still fatal - there is nothing to kill but itself.

That fix in turn exposed one in init: a service killed by a signal carries the signal in the low seven bits of the wait status and leaves the exit-code byte zero, so an init reading only the code byte reported a segfault as a clean stop. svc-crash came back STOPPED until SVC_KILLED existed.

Ctrl-C and process groups

The foreground-process-group machinery (g_console_foreground_pgid, vibeos_x86_64_console_interrupt, setpgid, TIOCSPGRP) has been present and correct for some time, and was reachable only from the PS/2 keyboard IRQ. The serial console - the one VibeOS is actually driven through, in CI and by hand - discarded 0x03 along with every other control byte in kernel_cli_read_line. The mechanism existed and no input could get to it, which is why nothing noticed. Ctrl-C is now routed to the signal path from there too, and the boot gate asserts it: sabotaging the branch turns the boot red.

What is not gated is the delivery itself. By the time the kernel CLI runs, userland has finished - init exits when its one session service does - so there is no foreground process for the signal to reach. The instrumentation says so in the log rather than leaving it to be assumed:

[SIG] console interrupt: delivered=0x0 newest_pid=0x0 fg_pgid=0x1

A sabotage case that removes the delivery call was written and came back NOT RED, which is a fact about this boot rather than a hole in the gate; it is kept in scripts/dev/cases/ctrl-c.txt as a comment so the next person does not rediscover it by hand. Gating delivery needs a live foreground job at the moment the key arrives.

Detectors, and what they found on their first run

Four things were added because the bugs in this kernel keep surfacing a long way from their cause, and each is aimed at a failure that actually cost time here rather than at a category of good practice.

DetectorCatchesFound immediately
Poisoned freed pagesuse-after-free, at the first dereference instead of the tenth-
Console-lock hygiene (bad_unlocks)an unlock from a core that never held the lock-
Log-integrity check in the gateone kernel write cut into anotherfour unbracketed multi-part messages
svc-stress, seeded and self-checkingrandomised fork/mmap/COW/pipe churna copy-on-write defect, replayable

The log-integrity check is the one worth explaining. Every assertion in the boot gate reads the serial log, so a split line can invent a failure or hide a real one - and for one session it did exactly that, producing two full investigations into crashes that were a marker cut in half. The check therefore runs before anything is concluded from the text. Its first run flagged four messages that were assembled without bracketing ([EXEC] three times, [SIG] deliver once); all four are fixed. It carries its own self-tests, including one line that must not be flagged - a ring-3 write with no trailing newline legitimately leaves the physical line open, and the first version of the check called that a defect.

svc-stress runs 120 rounds of randomised work with a seed printed on its first line, so a failure is replayable: EFI/BOOT/SVC_STRS.ELF <seed>. Every operation checks its own result, because churn nobody verifies only asks whether the kernel crashes, which is the weakest question available.

Open, found by the stress run on its first serious outing: at seed 280000012, round 13, a forked child wrote its own copy-on-write page and read back something other than what it had just written - STRESS_FAIL: the child could not keep its own copy. About one boot in sixteen. This is the first time one of these has arrived with a reproduction recipe attached.

Pending

  • Dependency ordering and exponential restart backoff exist only in the host-tested model (user/servicemgr/supervisor.c). The manifest the guest actually runs has no dependencies between its services and restarts immediately; the two are not yet the same code.

  • Declarative/service-config source of truth for init graph nodes (currently call-site supplied).

  • Ctrl-C delivery to a live foreground job is not gated (see above); only the routing of the key into the signal path is.

  • Stronger failure domains. A crashing service is now contained to its own task, but nothing yet limits what one service can do to shared kernel state before it faults.

  • Intermittent corruption of a user process's memory after fork, ~1 boot in 32. Two sightings, one family: musl tripping over its own malloc free list (nontrivial_free, writing through a chunk pointer read out of the heap), and a local variable holding a stale user pointer where fork()'s return value should be - the child took the parent's branch and the signal test never completed.

    One cause found and fixed: frame_ref_inc/frame_ref_dec were a plain read-modify-write on a byte, called from several cores at once. A lost increment leaves the count one owner short, so a shared page is freed while another process is still running from it. They are compare-exchange now. The musl-heap signature has not reappeared in 48 boots; the second signature appeared once in 32 after the fix, so the family is not closed.

    Second cause found and fixed: there was no TLB shootdown. hw_share_user_leaf clears PTE_WRITE to mark a page copy-on-write and called hw_invlpg, which reaches the calling core's TLB and nothing else. A thread of the same process on another core kept its cached writable entry and wrote straight through into a page the child had just been given a share of - no fault, no copy, and the damage surfacing much later in whichever program the page ended up serving.

    fork now sends a fixed-delivery IPI (vector 0xFE) and waits for each target to reload CR3, once per fork rather than once per page. The wait is what makes it a barrier: when it returns, no other core can still be writing through the permission just revoked. It is bounded and says so if a core does not answer, because a shootdown that hangs is a silent machine.

    It goes only to the cores actually running that address space. The first version broadcast to everybody and timed out about three boots in thirty-two: syscall clears IF (SFMASK is 0x200), so a core sitting in a syscall cannot take the IPI until it returns to ring 3, and with this much serial output that is most cores most of the time. Waiting on cores with no stake in the mapping bought nothing and was paid for in stalls. A single-threaded fork - nearly all of them - now sends nothing and waits for nothing.

    The copy-on-write fault needs the same treatment, and that was found the hard way. When it copies, the page's physical address changes; a thread on another core keeps the old frame and from then on reads and writes a page nobody else can see. TFORK.ELF hung outright on it: the flag its worker was spinning on lived on a copied page, so the store the parent made was invisible to the worker forever.

    That exposed a second requirement: once one core resolves a copy-on-write fault, another thread can still fault on a stale entry for a page that is now plainly writable. hw_handle_cow_fault used to see no COW bit and let the task be killed for a protection violation it had not committed; it now recognises the already-writable case and just invalidates.

    tests/linux/musl_tfork.c (staged as TFORK.ELF) is what exercises any of this: it forks while a worker thread is still running and checks that the child keeps the pages it forked with. Nothing else in the boot produces that shape, and before it existed the shootdown count was zero on every boot - the code was correct in principle and never once executed.

    What is gated is the mechanism, not the absence of the corruption: COW_STATS reports tlb_shootdowns and tlb_acks, and the boot fails if the threaded fork shot down nothing, if acknowledgements came back short, or if a shootdown timed out. Three sabotage cases in scripts/dev/cases/tlb-shootdown.txt each turn the boot red - and the case file records which check caught which, because removing the shootdown is caught by the counter rather than by TFORK's own data check.

    A corruption that appears one boot in thirty cannot be asserted on in a single boot. Pretending otherwise is how the earlier claims here came to be wrong, twice.

  • A hang in the bootloader phase, ~1 boot in 24. Previously written up here as a benign cold-start stall; that was wrong. Raising the per-boot budget from 180s to 300s does not help - the guest stays silent for 296 of 300 seconds at bootloader_exit_boot_services - so it is a hang and not slowness. Not yet investigated. Two signatures seen, and they may be one bug: a stack-check failure in the musl signal test (the canary is read through %fs:0x28, and the sigframe saves the trapframe, which does not carry FS_BASE), and init faulting inside its own say() while reading a pointer that landed in kernel memory - rip in say, cr2=0x21b6000, err=0x5, meaning the page is present but supervisor-only. Both are a user program dereferencing an address that is not its own, which is the family described under copy-on-write and the exec staging buffer in CLAUDE.md.

    Two things changed about it rather than one. It used to halt the machine silently; a ring-3 fault now kills the task, and the boot gate asserts that exactly one deliberate ring-3 fault occurs, so this shows up as a named failure (deliberate_ring3_faults=2_expected=1) instead of a wedge. Not yet instrumented far enough to say which pointer, so this is evidence and not a diagnosis. The canary is read through %fs:0x28, so a TLS base that is not preserved across signal delivery or fork would produce exactly this, and the sigframe itself saves the trapframe, which does not carry FS_BASE. Not yet instrumented, so this is a lead and not a diagnosis.

  • Declarative init configuration format and validation.

  • Runtime health probes and process-backed failure-domain isolation remain pending.

Next checkpoint

  • Replace fixed runtime program selection with ring-3 init execution and service supervision.

Interrupt Handling Progress

Status: In Progress (CPU traps, PIT and APIC timer paths verified) Last review: 2026-08-01

Implemented

  • Interrupt controller registration/dispatch primitives in kernel/core/interrupts.c.
  • IRQ diagnostics counters and mask/unmask controls.
  • Denied-dispatch diagnostics with explicit cause counters (bad_irq, unhandled, masked, disabled) and counter reset API.
  • x86_64 IDT scaffolding (host model) in kernel/arch/x86_64/idt.c.
  • Real on-metal GDT + IDT bring-up in kernel/arch/x86_64/arch_hw.c + isr.S (image-only, not host tests):
    • Minimal flat GDT (kernel CS=0x08, DS=0x10) with lgdt and far-return CS reload.
    • 256-entry IDT with 64-bit interrupt gates, lidt, and assembly ISR stubs for CPU exceptions 0-31.
    • Uniform saved-register frame passed to a C handler; CR2 capture on #PF (vector 14).
    • Invoked from entry.s before vibeos_kmain, preserving RDI/RSI handoff registers.
    • Verified in QEMU+OVMF: int3 self-test raises a real #BP, is caught by the ISR (vector=3, rip in kernel .text), resumes via iretq, and boot continues to CLI (HW_INIT_OK).
  • Trap state and classification flow in kernel/arch/x86_64/trap.c.
  • Extended trap decision flow with explicit actions (CONTINUE, KILL_CURRENT, PANIC), user/kernel CPL classification, fault-address tracking, action counters, and kernel-log integration for directed fault diagnostics.
  • Resumable-debug-trap handling: #DB(1)/#BP(3)/#OF(4) now yield CONTINUE instead of a kernel PANIC (covered by test_trap_debug_resumable).
  • Real on-metal ISR handler routes CPU exceptions through vibeos_trap_dispatch_ex using a bring-up-local trap_state, honoring the returned action (CONTINUE resumes via iretq; PANIC/KILL halt during bring-up). Verified in QEMU: real int3 -> dispatch -> action=CONTINUE count=1 -> resume.
  • Timer IRQ binding path from interrupt controller to timer source (host model).
  • Real hardware timer IRQ on metal (arch_hw.c): 8259 PIC remapped to vectors 0x20-0x2F, PIT programmed to 100 Hz, IRQ ISR stubs 32-47, EOI handling, sti, and a tick counter incremented from IRQ0. Verified in QEMU: TIMER_IRQ_OK ticks=3 (bounded spin confirms periodic delivery), boot continues to CLI with interrupts enabled.
  • Local APIC initialization and AP startup now provide the runtime interrupt foundation for SMP scheduling; the legacy PIC/PIT route remains the early/QEMU-compatible timer path.

Pending

  • Point the on-metal handler at the live kernel trap_state and current PID (currently a bring-up-local trap_state) so KILL_CURRENT can terminate a real process.
  • Drive the scheduler from the real timer tick (preemption) instead of the host-modeled timer path.
  • APIC / IO-APIC support (currently legacy 8259 PIC; only IRQ0 unmasked).
  • SMP interrupt routing policy.
  • Interrupt affinity, masking and teardown policy for device interrupts under concurrent task scheduling.

Next checkpoint

  • Connect live task ownership to the exception decision path and validate user-fault kill versus kernel panic under the active ring-3 runtime.

I0 and I1 — the block layer

What was there

Three functions with three shapes — read(lba, buf), read_many(lba, buf, n) and write(lba, buf) — bound to one global device, each returning a bare int. The asymmetry was the informative part: there is no write_many, and nobody had noticed.

I0 — the contract

A request is a value now, with a result that names the reason. Six of them, so a timeout, an absent device, a medium error, a short transfer, a malformed request and a range past the end of the disk are six different things instead of one -1.

This project has already paid for the old shape once. fat_next_cluster reported a failed table read as the end-of-chain marker — the same value a healthy last cluster returns — so a flaky sector produced a short file that said it was complete, and execve parsed whatever the previous program had left in the shared buffer.

The layer is three things: validate, dispatch, and compare what came back against what was asked for. Only the third is new, and it is the one the above needed: a driver that moves four sectors of eight and returns success is reporting a success that lost half the data.

The bounds check lives here, not in the driver. A driver that checks its own bounds protects the device; it cannot protect a neighbouring partition, whose sectors are perfectly valid addresses on that device. The same argument the swap area makes about the filesystem, one layer down.

A defect the sabotage found in the layer itself

The first version indexed the statistics array with the driver's result directly. A driver is not trusted to return a value from that enum: an out-of-range result wrote past the end of the array, and the suite segfaulted instead of failing an assertion. count_result() bounds it now, and counts an undefined value as BAD_REQUEST rather than dropping it — a driver returning something nobody defined is itself worth knowing about.

The same case exposed a test that could not reach what it claimed. Every refusal path sets a reason explicitly, so removing the reset at the top of submit changed nothing the tests looked at. The path that needs it is a driver failing silently, where the layer decides "did the driver say why?" by asking whether the result is still NOT_ISSUED — and a caller's stale reason would otherwise be attributed to this request.

I1 — the layer becomes load-bearing

blk.c is an adapter now: everything above still calls vibeos_x86_64_blk_read, and underneath that is a request through the new layer.

An adapter rather than a rewrite of the callers, deliberately. fat.c is how this machine boots and has nine call sites; changing the layer beneath it and its own calls in one step would mean a failure could be attributed to either. The callers move later. This makes the new layer load-bearing today, on every boot, with nothing above it changed.

AHCI learned how big its disk is

It had never issued IDENTIFY, so every AHCI read was unbounded — an LBA past the end of the medium went straight to the controller. The new layer refuses to register a device that cannot state its size, which turned that from an improvement into a prerequisite.

ahci_xfer was generalised into ahci_cmd rather than duplicating forty lines of FIS construction. Two things that file had already learned the hard way live in that construction — the PRDT count is one less than the byte count, and the error bit must be checked inside the wait rather than after it — and a second copy would have had to learn them again.

LBA48 is read only when word 83 says the 48-bit fields mean anything; otherwise LBA28. A driver that reads them unconditionally gets a plausible number from a disk that never filled them in.

What a boot now says

[IO] MUSTBEZERO medium=0 short=0 timeout=0 bad_request=0 out_of_range=0 register_refused=0
[IO] BLK reads=0x4f95 writes=0x1e sectors_read=0x4f95 sectors_written=0x1e devices=1

Twenty thousand reads and thirty writes, with every error counter at zero. All six of the first line are asserted; NO_DEVICE deliberately is not, because a machine with no disk is a configuration rather than a failure.

The counter corrected the plan within minutes of existing

docs/io/README.md stated, as a surveyed fact, that no write is ever exercised. That was wrong. A boot does thirty sector writes — the shell's mkdir DOCS reaches vibeos_fs_mkdir, which reaches FAT, which writes both copies of the table.

The claim had been made by reading code and grepping for callers, and it survived being written into a plan. What caught it was a number. The plan is corrected, and the accurate statement is narrower and still worth acting on: writes reach the medium and nothing verifies that what was written is what comes back.

Verified

  • Ten host-test groups for the contract, six sabotage cases in io-contract.txt, each confirmed red.
  • Three boot-level cases in io-blkdev.txt. One of them is recorded as behaving differently from intended: breaking every transfer stops the machine before the counters are printed, so it is indistinguishable from a broken disk — which is arguably the honest outcome.
  • The gate assertion proved by a request with a null buffer at bind time: invariant_failed:io_bad_request=1. That case was chosen over breaking a transfer precisely because a sabotage that destroys the thing it is testing proves nothing about the test.
  • 8/8 clean boots, gcc and clang green.

Not done

The callers still use the old three-function API. fat.c reaching the disk directly, and the three parallel read paths, are I2.

Which disk is the boot disk

The Filesystem Runtime Gate had been red for days. The AHCI job wedged; the virtio job was green. This is what it was, and — more usefully — why nothing in the I/O stack could have reported it.

The symptom

[QEMU-CLI] FAIL missing:VIBEOS_SELFTEST_DONE verdict=guest_wedged
           phase=kernel_cli_ready

Reproduced locally on the first attempt with VIBEOS_SMOKE_DISK=ahci. Deterministic, which is worth saying: most failures in this project are not, and a deterministic one is a gift.

Every counter was zero and every counter was right

[BLK] disks=0x2 disk=virtio-blk:0x0 disk=ahci:0x1
[BLK] disk driver: virtio-blk
[IO] BLK reads=0x1 writes=0x0 sectors_read=0x1 devices=0x2
[IO] MUSTBEZERO medium=0 short=0 timeout=0 bad_request=0 out_of_range=0 ...
[COMPAT] linux syscalls translated=0x0 denied=0x33
[SCHED] task pid=... exited code=0x7f
[EXEC] refused reason=not-found path=EFI/BOOT/SVC_BOMB.ELF at=read_file

One read. One sector. The entire boot. Every must-be-zero counter in the block layer read zero, and none of them was wrong: the read succeeded. It simply was not a filesystem.

That is the general lesson and it is a new one for this project's collection: a machine that reads the wrong disk perfectly has nothing to report. Every detector in the I/O stack watches for a transfer that went wrong. None of them can see a transfer that went right, to the wrong place.

What named it was one line — disk driver: virtio-blk on a machine whose ESP was on AHCI.

The cause was a sentence that stopped being true

From blk.c, above blk_boot():

"Adapter 0 is the first driver that came up, which is the one the machine booted from — every caller above this layer means that one when it says 'the disk'."

True for exactly as long as the machine had one disk. I5b added a second one, deliberately on the other controller, so that the kernel log lives on a medium that is writable when the boot filesystem is not. With VIBEOS_SMOKE_DISK=ahci the ESP is on AHCI and the log is on virtio-blk, so adapter 0 is a blank 4 MiB raw file.

The commit that made the sentence false also restated it, four lines below the change that broke it:

"The order still decides which one is 'the disk': virtio binds first and keeps that meaning for every caller above."

Written down as a deliberate choice, in the same change that introduced the configuration it cannot survive.

The same assumption, twice, one file apart

Fixing blk.c moved the failure rather than closing it: the boot volume then mounted correctly and the log sink wrote over the ESP, which QEMU's vvfat reported as Tried to write to protected bootsector and the boot reported as io_medium=73.

io_bringup.c had the mirror of the same belief:

/* Adapter 1: the second disk that bound. Adapter 0 is the boot disk and is
 * deliberately not eligible ... */
dev_no = (adapter_count > 1u) ? vibeos_x86_64_blk_adapter_device(1u) : -1;

With the ESP on AHCI, adapter 1 is the boot disk. Two places encoded the same false invariant, neither knew about the other, and nothing checked either.

The fix

Ask the question that can actually be answered. The block layer cannot tell a boot volume from a blank medium — a layer guessing at something it cannot check is how this got written in the first place — but the filesystem can. So the filesystem chooses:

  • vibeos_x86_64_blk_set_boot(n) points the no-argument disk path at adapter n; the bring-up tries each until one mounts. Bind order decides nothing.
  • vibeos_x86_64_blk_boot_adapter() answers "which one is it", and the log sink takes the first adapter that is not that one instead of hardcoding 1.
  • The [BLK] disk driver: line moved to after the selection. It used to be printed before anything was mounted, so it could not report anything except bind order: the field a human reads to check which disk was used was structurally incapable of showing the bug. That is not a cosmetic move, and it is why this cost days.

Cost: one sector read per rejected disk, once per boot.

Verified

verdictline
virtio (default)status=passboot volume on virtio-blk rejected=0x0 disks=0x2
AHCIstatus=passboot volume on ahci rejected=0x1 disks=0x2

Both controllers green. rejected=1 on AHCI is the selection doing work; 0 on virtio is it correctly doing none.

Gated

A new assertion reads the [BLK] boot volume on line and fails on a missing report, on none, and on a rejection count that exceeds the number of disks.

It is asserted on both runs, not only the AHCI one. The failure it really guards against is the selection quietly going away: on the default machine the boot disk is adapter 0 anyway, so a regression there is invisible in the verdict and visible only in that line.

Deliberately not asserting rejected > 0 — that holds on AHCI and not on virtio, and an assertion that is only true in one configuration is one somebody will loosen.

The sabotage run

Making vibeos_x86_64_blk_set_boot return without assigning reproduces the CI failure exactly:

status=fail
reason=missing:VIBEOS_SELFTEST_DONE verdict=guest_wedged elapsed=50s
       quiet_for=45s phase=kernel_cli_ready
[BLK] boot volume on none rejected=0x1 disks=0x2

One thing that run showed which the plan for it did not: the report says boot volume on none, so the new boot_volume_on_no_disk assertion describes the state correctly — and never fires, because the wedge is detected first and short-circuits the invariant checks. The case makes that assertion true; nothing has yet observed it turn a boot red on its own. Recorded rather than glossed, because "no case exists", "the case exists and this environment cannot tell", and "the case exists and something else fails first" are three different things.

Cases in scripts/dev/cases/io-boot-disk.txt.

What this says about the core plan

Two files encoded the same false invariant about which adapter is the boot disk, and the checker that would have caught it does not exist. The invariant is exactly the shape check-blast-radius.py and check-subsystem.py are specified for in docs/core/: a fact about a registry that callers reach in and assume, rather than ask.

It also strengthens C0's case for itself. This was not found by the plan, by review, or by reading — it was found by looking at CI, and it had been sitting red long enough that "the gate is red" had stopped carrying information. A check that has been red since before you arrived is a check nobody is reading, applied to the boot gate itself.

ext2, mounted from an image this project did not write (I5)

The ext2 driver has been in this tree for months. It had never parsed anything.

That is the whole finding, and it is the third time this session it has been the finding: the swap stack, the block cache and the volume scan were each correct code that nothing called, and each was declared working on the strength of having been written. A driver nobody runs is indistinguishable from a driver that works, right up to the moment somebody tries it.

What now happens on every boot

hw_ext2_bringup attaches EFI/BOOT/EXT2.IMG through a loop device, gives it a block cache, mounts it, reads a file out of it, compares the bytes, and attaches it to the mount table at /ext2. A boot says:

[IO] EXT2 sectors=0x0000000000001000 result=OK
[IO] MOUNTED at=/     type=fat
[IO] MOUNTED at=/vol1 type=fat
[IO] MOUNTED at=/ext2 type=ext2

Two filesystem implementations, mounted at once, on one machine. That was the point of I4b's mount table and of the FAT singleton refactor; this is the first thing that could not have been done before either of them.

The image is built by mke2fs, deliberately

scripts/make-fs-image.py shells out to the host's mke2fs. An image this project wrote itself would only prove that the driver and the writer agree with each other - and they would, because the same misreading of the layout would go into both. The one artefact neither side of the test controls is the one worth mounting.

It is asked for revision 0 with -O ^resize_inode,^dir_index,^ext_attr,^has_journal, because a modern mke2fs turns on extents, 64bit and metadata_csum by default, which is ext4 wearing an ext2 name. Turning them off is not making the test easy; it is making it a test of ext2.

The file inside carries its own offset in every byte. A file full of a constant survives a read that returned the wrong block, as long as that block had been written too - which for a multi-block file is exactly the failure worth catching.

Why a loop device rather than a second disk

kernel/arch/x86_64/loopdev.c presents a contiguous file on the boot volume as a read-only block device, reusing the extent resolution the swap area already needed. The honest alternative - a second physical disk - requires virtio-blk to stop being a singleton, which is a refactor of the driver the machine boots from, and it is not what I5 is about.

The device refuses writes rather than dropping them. A device that accepts a write it does not perform tells the caller its bytes are safe.

It also refuses a fragmented file. This is the sharpest edge in the change: a loop device that spanned a gap would hand the filesystem above it another file's bytes, and ext2 would parse them - a mount that succeeds and is wrong. The staged image comes out as one run on this medium, so that refusal cannot be sabotage-tested here; it is written up in the case file as a case this environment cannot decide rather than a case that does not exist.

What is gated

ext2_exercise_missing if the bring-up stops running, and the verdict itself if the mount or the read fails. Confirmed by deleting the call: the gate goes to reason=invariant_failed:ext2_exercise_missing.

A missing image is reported, not failed. A machine without e2fsprogs still boots, and the build says why rather than dying somewhere a developer has to guess about.

What this does not prove

Reading. Nothing writes to an ext2 filesystem here, and the loop device could not carry it if it tried. Directory traversal is one level deep. Neither is a gap in the phase - I5 is "these filesystems have never been mounted from a real image" - but neither should be read as more than it is.

The remaining filesystems (NTFS, ISO9660, exFAT) get the same treatment, one at a time. One that cannot be given a real image in CI should be deleted rather than kept: an unrunnable driver is the state this file exists to record.

H-013: a block pointer past the volume (fixed 2026-09-11)

ext2_read_block turned any block number into part_lba + block * sectors, and the mount did not keep blocks_count, so nothing could have bounded it. Every block number the driver reads comes from the volume: direct and indirect pointers in an inode, and - not in the report - the inode table's location in a group descriptor, so a lookup alone could read past the volume. The block layer bounds a read by the device, not by the partition, so a pointer past the volume but inside the device returned another partition's sectors to user space.

The mount keeps blocks_count, and ext2_read_block refuses a block at or past it. Every read goes through that function, so one check covers every path.

test_ext2_block_pointer_outside_volume declares a volume half the size of its device and points a file's block into the other half, filled with a marker; the read must return nothing. Red on the old code, green now. The half-size volume is what makes the test honest: the host image's volume fills its device, and a pointer past it would have been refused by the device and passed for the wrong reason.

Host tests green, check.sh all green.

Writing a GPT, and which rule actually protects the disk (I4c)

I4c shipped with one gap: it could write an MBR and not a GPT. That is now closed, and closing it produced a finding that changed what this file was going to say.

What it writes, and in what order

Five regions, in this order, with a flush between each:

  1. the backup entry array
  2. the backup header
  3. the primary entry array
  4. the primary header
  5. the protective MBR

A flush between each because none of that ordering exists otherwise — the block cache is write-back, so issuing writes in order orders nothing. That is the same lesson journal.c records, and it is worth stating twice because the code looks correct without the flushes.

The protective MBR is last on purpose. Its only job is to stop an MBR-only tool from believing the disk is empty; writing it first would tell such a tool the disk is spoken for before it actually is. It is an edit of sector 0, not a rebuild, because that sector also holds boot code and a repartition must not unbootable a disk it was only asked to repartition.

Partition GUIDs are derived from the disk GUID and the entry index rather than invented. This kernel has no entropy source it could honestly call one, and a uuid sliced out of a hash is not a uuid — that mistake cost three attempts at the OVA, where VirtualBox parsed the malformed value to null and then reported that an image it could see did not exist. Derived and documented beats random and fictional.

How it is verified

Not by writing a table and reading it back. The device model has a volatile cache, as real drives do; a write is acknowledged immediately and sits there until a flush, and the flush applies pending writes in an order the test chooses. Power is cut after every possible number of landed writes, across three flush orders — 240 arrangements — and after each one the disk is examined as a fresh reader would examine it.

The invariant asserted is narrower than "all or nothing", and stating it precisely is what made the results readable:

A GPT header that validates always describes an entry array that is actually on the disk.

Plus: the boot code in sector 0 survives every prefix of the sequence, and a primary never lands without its backup.

The finding: the CRC protects the primary, the ordering protects the backup

The sweep was written believing the central danger was a header written before the array it describes — a claim about bytes, made before the bytes exist, producing a table that passes every check and points at rubbish.

Sabotaging exactly that caught nothing. On looking at why, the premise was wrong. The header carries a CRC over the entry array. Written early, it describes an array that is not there yet, so it does not validate, so a reader rejects it and falls back to the backup — a recoverable state, not a lie. For the sabotage to produce a lying header, the previous contents of the array region would have to satisfy the new header's CRC, which is not something that happens.

So the ordering between the primary header and its array is not load-bearing; the CRC already is. What the ordering genuinely buys is the relationship between the two copies — deleting the backup header write is caught immediately, because then a primary can land alone and the disk carries two tables that disagree with no way to say which is newer.

The ordering is kept because it costs nothing and is what the standard expects. It is not kept because a test showed it mattered, and this file says so rather than implying otherwise.

That is the third time in this project a test has been right about the outcome and wrong about the mechanism. The difference this time is that the sabotage was run before the claim was written down, so the claim never became folklore.

Not journalled, deliberately

The plan for I5c assumed the partition-table writer would be the journal's second customer — "two customers in two layers is what makes it a module rather than a feature". Having built it, that is the wrong call for this writer.

A GPT already carries its own recovery scheme: two copies, each with a CRC over the header and another over the entry array. A reader that checks those can tell a good table from a torn one without help — and the sweep above demonstrates it does. Putting a journal underneath would add a second recovery mechanism that has to agree with the first, which is a second place that has to be right; this project has spent whole phases removing second places.

What the journal is for is updates that have no such scheme of their own. The second customer, when it arrives, should be one of those.

ISO9660, and a test that agreed with the bug (I5)

ext2 mounted on the first attempt. ISO9660 did not, and what it found is the clearest justification this phase has produced for its own premise.

The defect

vibeos_iso9660_mount read the logical block size at offset 128 of the primary volume descriptor with a 32-bit little-endian load. That field is a both-endian pair of 16-bit numbers: four bytes, but two copies of a 16-bit value, not one 32-bit value. For the 2048 every ISO in existence uses, the four bytes are 00 08 08 00, and a 32-bit little-endian read of them is 0x00080800.

So the check != VIBEOS_ISO_SECTOR was true for every well-formed image, and the driver refused every ISO ever made. It has been in the tree for months.

The comment two lines above the defect warns about the both-endian trap - for the fields that really are 32-bit - and the code below it walked into the 16-bit version of the same trap.

Why the host test could not see it

There was a test. test_iso_lookup_and_read mounted an image, walked a directory, read a file and checked the bytes. It passed.

It passed because iso_build() wrote the block-size field with iso_w32both - the same misreading, on the writing side. The fixture and the driver agreed with each other, and agreeing with each other is all a self-built fixture can ever demonstrate.

This is the fourth time in this project that a test has been right about the outcome and wrong about the mechanism, and it is the most direct: the property under test was correct, the arrangement made the defect unobservable, and only an artefact from outside the project could tell the two apart.

The fixture now writes a 16-bit both-endian pair, per the standard. Confirmed by putting rd32le back: three host tests go red, test_iso_refusals among them. The corrected fixture was verified against the old driver before the driver was changed.

What runs now

The bring-up became a table. The first version was one function per filesystem, and the second would have been a copy of it with four names changed - which is how a family of checks ends up with one member asserted and the rest not.

[IO] FSIMAGE name=ext2    sectors=0x0000000000001000 result=OK
[IO] FSIMAGE name=iso9660 sectors=0x00000000000002f0 result=OK
[IO] MOUNTED at=/     type=fat
[IO] MOUNTED at=/vol1 type=fat
[IO] MOUNTED at=/ext2 type=ext2
[IO] MOUNTED at=/iso  type=iso9660

Four mounts, three filesystem implementations, one machine.

The image comes from the host's xorriso, with no Rock Ridge and no Joliet. Those are extensions; the driver under test reads plain ISO9660, and an image whose real names lived in an extension it does not implement would test the fallback rather than the filesystem.

What is gated

fsimage_missing:<name> per expected filesystem, and the verdict itself when a mount or read fails. The expected names are listed rather than counted: a count cannot tell "iso9660 stopped being exercised" from "ext2 ran twice".

What is left

NTFS and exFAT, the same way. Neither mkntfs nor mkfs.exfat is installed here, so both need a decision about tooling before they can be given a real image - and per this phase's own rule, a filesystem that cannot be handed a real image in CI should be deleted rather than kept. An unrunnable driver is exactly the state this file exists to record.

The kernel's own log, on a medium that outlives the machine (I5b)

Every hard defect in this project was diagnosed from a serial log — the silent wedge, the interleaved console, the copy-on-write corruption, all of it. On a developer's machine QEMU captures that. On an appliance, or on real hardware with no serial cable, a wedge left nothing at all, and the evidence behind the last several fixes would simply not have existed.

A boot now says:

[IO] LOGSINK result=OK capacity=0x1fff prev_seq=0x7 written=0x1 failed=0x0
     bad=0x0 previous=VIBEOS boot mark

previous= is the previous machine's last record, read back from the medium after the machine that wrote it had stopped.

Not the same thing as kernel/fs/journal.c. That is a write-ahead journal for filesystem consistency; this is the operating system recording what happened. The two share a word and nothing else, which has already caused one misunderstanding.

What had to happen first

The kernel could not have a second disk. vibeos_x86_64_blk_bind held one set of function pointers and refused the second driver — "first one to come up owns the disk" — which was right while a machine had one disk, and is why I5's filesystem images had to be reached through a loop device. A log on the boot filesystem is unwritable exactly when it is most wanted, so that singleton was a hard blocker, not an inconvenience. It is gone; see the commit that removed it, which also found that AHCI only ever worked because UEFI had prepared its port.

The three decisions that are not obvious

No lock on the write path. The context that most needs to write is a panic handler, where another core may hold any lock and the scheduler is parked — a lock here is a way for the last line before a crash to be the one that never arrives. The sequence number is a single atomic fetch-add and the slot is derived from it, so two cores writing at the same instant get different sequence numbers, therefore different sectors, and never touch each other's. The only other shared state is the staging buffer, and there is one per core.

No header update per record. The obvious design keeps a head pointer in sector 0 and writes it after every record — a second thing to lose at exactly the wrong moment, and double the I/O on the path whose latency matters most. Each record carries its own sequence number and the head is recovered by scanning at attach. A boot pays one pass over the medium; a lost head pointer costs the log.

Write-through, never write-back, never through the block cache. The last few lines before a crash are the entire point. Same ordering contract as I4's barrier, used for the opposite reason: the journal wants ordering so it can batch, this wants it to refuse to.

Two things that were got wrong first

A claim in a comment that the code did not support. The module said the checksum covering the sequence number is what stops a stale slot from impersonating a fresh one after a wrap. Sabotaging that rule was caught by nothing, because the reader compares the sequence it got against the one it asked for and catches staleness whatever the checksum covers. What the rule actually guards is attach, which validates records with no sequence to compare against: one flipped high bit would set the next sequence to something enormous for the rest of the life of the medium. The comment was corrected and the test that makes the rule mean something was written afterwards — the sabotage came first and the test second, which is the order that produces a test that can fail.

A bring-up that read one sector at a time. Recovering the head over a 4 MiB medium meant 8191 synchronous single-sector transfers, and boots began wedging about two in four. It did not look like a slow log; it looked like a stopped machine, and the two failures it produced — a wedge in busybox_cat and the known intermittent bad-args — point nowhere near a log. It was identified by shrinking the medium to 512 sectors and watching four boots go green, and then fixed properly by batching the scan rather than by keeping the medium small. The scan is the only batched path; the write stays one sector, because batching there would be the write-back this module exists to refuse.

What is gated

logsink_missing if the sink stops being brought up; the verdict itself; logsink_wrote_nothing; logsink_write_failed; and the one that is the feature: logsink_lost_the_previous_boot — once the medium carries records from an earlier machine, the previous boot's last line must come back.

That assertion is conditional on prev_seq > 0 deliberately. A fresh medium legitimately has nothing on it, so previous=none cannot simply be banned; the gate keeps qemu-cli-logdisk.img between runs, so the first run in a working directory exercises the empty branch and every run after it exercises persistence.

The bring-up reads record 0 before it writes this boot's mark. Reading it after would make the newest record this boot's own, and the check would prove that a write followed by a read works — nothing about persistence at all. That ordering is load-bearing and is written down in the case file as a case that would pass.

Nine host tests, sabotage-verified: six turn the suite red on the property they guard, one is recorded as the case exists and this environment cannot decide it (two cores sharing a staging buffer cannot be constructed in a single-threaded host test, and nothing yet logs from more than one core), and one — a failed write also counted as written — was initially reported as uncaught because the sed that was meant to break it never applied. Third time that trap has been walked into here.

The second half: the log itself, and the panic

Every hw_log event now goes to the medium — every one, including those below the serial level, because the quiet lines nobody was printing are exactly what is wanted after a machine has stopped. A boot raises a few dozen, so the cost is a few dozen sector writes against the several thousand reads a boot already performs.

The formatting is shared between the serial writer and the sink so the two cannot drift, and it neither allocates nor locks, because it runs from a panic handler. hw_panic writes its reason to the medium first, before anything else it does: every line after that is one more chance to stop before reaching the disk.

A per-core re-entrancy flag guards the one loop that would otherwise exist: the sink writes through the block layer, and the block layer logs when a request is refused. One flag per core rather than a global — two cores logging at once are not recursion, and a global would silently drop the second one.

logdisk on the console prints the tail, newest first. Deliberately not named log: that already prints the two in-memory rings, and two commands whose names differ by nothing would be read as the same thing. This one answers a different question — what survived the last machine.

[LOGDISK] seq=0x13ad [DEBUG] task exited (a0 = pid, a1 = code) ...
[LOGDISK] seq=0x13ab [DEBUG] futex wake code=0x14 a0=0x8000800690 ...
[LOGDISK] seq=0x13a9 [DEBUG] copy-on-write fault resolved ...

The gate drives the command and asserts more than the boot mark rather than merely non-zero: a sink holding only its own bring-up would satisfy a non-zero check, and that is precisely the state the first half of this phase left.

One thing that came out of this and is not explained

Sabotaging the feed — removing the one call that hands a log event to the sink — wedged the machine on three boots out of three, before the console was reached. That is not the known intermittent, which is about one in six and does not repeat like that. Removing a disk write from the kernel log path should not stop the machine, so something on that path depends on the timing it provides.

It means the kernel-side sabotage could not reach the new assertions, so those were confirmed by moving the gate thresholds instead — which proves the parse and the branches, and is weaker than breaking the code and watching it go red. Both facts are recorded as they are in scripts/dev/cases/io-logsink.txt.

Worth chasing before I6: asynchrony will fold this into the noise.

I4b steps 3 and 4: a probe, and somewhere to put a second filesystem

Status: done. The scan mounts what it finds, the machine says what it has mounted and where, and the mount table has five sabotage cases of which three turn the host tests red today.

Step 3: the probe, and the gap it closed

Steps 1 and 2 ended with a measurement: the scan found a 504 MB volume, identified it correctly as FAT, and reported fs=none. The only FAT driver on the machine was invisible to the code doing the identifying — it lives in the arch layer, and kernel/fs/ naming it would invert the layering the whole storage refactor exists to establish.

So drivers that live outside kernel/fs/ register instead: vibeos_storage_register. The boot now says

[IO] VOLUMES table=mbr partitions=0x1 volumes=0x1 mounted=0x1 disk_sectors=0xfc000
[IO] MOUNT at=/ type=fat
[IO] VOLUME idx=0x0 first_lba=0x3f sectors=0xfbfc1 kind=fat fs=fat

mounted=1, fs=fat.

Probe and mount are separate now, and they were the same function: each driver's probe was its mount, and a volume went to whoever mounted it first. A driver that gets half way through a mount and fails leaves state nobody unwinds, and every probe paid a full mount — which for ISO9660 is a real read a long way into the volume. A probe reads, answers, and keeps nothing.

The order is a decision. NTFS and exFAT are probed before FAT because both live in a boot sector that is a FAT boot sector with different fields. A FAT probe that checks only the jump and the 0xAA55 signature says yes to an exFAT volume — and a FAT driver that mounts one does not fail, it produces files full of nonsense. So the FAT probe checks the two fields exFAT leaves at zero precisely so this cannot happen: sectors-per-cluster and the FAT count.

One limitation, stated rather than hidden. fat_scan_mount takes a first_lba and ignores it: the driver finds its own partition by parsing the MBR itself. It therefore mounts the volume it would have chosen anyway — the same one here, and the wrong one on a disk with two FAT partitions. Threading the offset through fat.c's globals is a change worth making on its own.

Step 4: the mount table

There was one global mount, and that was the structural reason only one filesystem could run — not a missing driver, a missing place to put a second one.

A fixed array of eight, no allocation: these paths are reached from syscalls and from the boot, and a table that allocates fails exactly when the machine is short of memory, which is when programs are most likely to be opening files.

Three properties, each with a sabotage case that turns the tests red:

Longest prefix wins. /usr/lib beats /usr beats /. Taking the first match instead makes resolution depend on the order things happened to mount in, so the same path answers differently on two boots — the kind of defect that appears months later on the first machine with an extra volume.

A prefix must end on a boundary. Without that, /usrlocal resolves to a mount at /usr. The answer is wrong and reads perfectly reasonably in a log, which is the worst combination this project knows.

Two mounts may not claim one path. Refused, not overwritten: overwriting leaves the first filesystem mounted and unreachable, files open and blocks dirty, with nothing able to name it to unmount it.

The tests also check what happens when a parent detaches — the table is flat and the entries independent, so /usr/lib survives /usr going away, and what was under /usr falls back to the root.

What is deliberately not done

The syscalls still take g_rootfs directly. Moving them onto the resolver is what makes a second mount reachable, and doing it in the same step as building the table would mean neither is verified. What the table gives today is the ability to say what is mounted, which is the phase's "done when" and which a machine with one global mount could not do at all.

"Mounts more than one" is not demonstrated. This medium has one partition and one driver that can claim it. Asserting two mounts would be asserting a disk image this build does not produce — that is a change to the image, not to the kernel, and it belongs with I4c where a partition table gets written.

An unrelated finding, recorded so it is not lost

The intermittent bad-args execve refusal fired again during this work, and gave the same reason as last time:

[EXEC] refused reason=bad-args path=EFI/BOOT/SVC_STRS.ELF at=argv:pml4_absent_or_not_user

Twice now, identically. Not a leaf permission problem: the top-level page table entry for the argv vector's address is absent, in the child of a fork reading its own .bss a moment after writing it through a copy-on-write fault. That is a stable signature and the next thing to chase in that subsystem.


The singleton is gone, and both "done when"s close

fat.c had one fat_fs_t g_fat with 84 references across 1200 lines, and that was the second structural reason only one filesystem could run — the first was the VFS mount table, one layer up. The boot now says:

[IO] MOUNTED at=/ type=fat
[IO] MOUNTED at=/vol1 type=fat

Two volumes, on two devices, at once. /vol1 is the scratch device that I4c partitions and formats, and the cycle the plan asks for — partition, format, mount, write a file, unmount, re-read — runs on every boot. The read back goes through the mount table's resolver, not through the mount handle, so what is proved is that a path under /vol1 reaches that volume and not the root.

How it was done, and what it costs

A current-volume pointer, not a fat_fs_t * threaded through thirty static functions. Both are correct; this one is a change that can be read in an afternoon and verified by a boot, and the alternative is a wide edit to the driver the machine boots from.

The pointer is safe for exactly one reason, and it is worth stating rather than assuming: every public entry point takes fs_lock for the whole operation, so there is never more than one in flight and never a moment when the current volume is ambiguous.

What it costs is real: two volumes cannot be read at the same time. This driver already serialised everything through that lock, so nothing got slower — but it is the limit to lift if a second volume ever carries traffic, and lifting it means threading the parameter after all.

The defect this design produces, produced immediately

An entry point that does not select operates on the wrong volume, silently.

vibeos_x86_64_fat_file_extent took the lock and did not select. The moment a second volume existed it read that one, and the swap area reported that this medium has no swap file — a failure that points at swap and has nothing to do with it.

Three entry points were missing a select. They are all fixed, and the audit is one grep over the file: every function that calls fs_lock must call fat_select. There is a sabotage case for it, because this is the failure mode the design has, and a design with a known failure mode and no check for it is worse than the parameter it avoided.

Also on the way

The definition-order trap three times in one change — g_fat_cur used above its declaration, fat_select used above its definition, and the driver table naming a formatter defined after it. This file's own comments warn about it, and it is still the thing that costs the most time in a 1200-line C file.

And a second set of extern declarations in fat_vfs.c had drifted the moment the entry points grew a volume argument. They are gone: the header declares them, and a second place to keep true is a second place to be wrong.

NTFS and exFAT, and the limit that made NTFS useless (I5)

Both drivers had been in the tree for months. Neither had parsed a byte. A boot now carries six mounts and five filesystem implementations:

[IO] FSIMAGE name=ext2    sectors=0x1000 got=0x1000 result=OK
[IO] FSIMAGE name=iso9660 sectors=0x2f0  got=0x1000 result=OK
[IO] FSIMAGE name=ntfs    sectors=0x8000 got=0x1000 result=OK
[IO] FSIMAGE name=exfat   sectors=0x8000 got=0x0    result=OK (no marker)
[IO] MOUNTED at=/ type=fat   /vol1 fat   /ext2 ext2
[IO] MOUNTED at=/iso iso9660 /ntfs ntfs  /exfat exfat

NTFS could not read the root of any real volume

ntfs_index_scan refused any directory whose entries lived in an $INDEX_ALLOCATION rather than in the resident index root. That reads like a limitation on large directories, and it is not: mkntfs gives the root directory one, because a fresh volume already contains sixteen metadata files. So the driver failed at the first lookup on every volume ever produced by anything other than this project.

The host test could not see it. Its image was synthetic, with a directory small enough to be resident — the one shape the driver could handle. Fifth time this phase has found code that worked only in the arrangement it was written against, with the arrangement doing the work.

The allocation is now walked: the run list is mapped VCN by VCN, each INDX block is read, its fixups undone, and its entries scanned with the same walker the resident root uses. Linearly, not as the B-tree it is — every entry appears in exactly one node, so a scan is correct, and descending would need the collation rules to make a bounded walk slightly faster.

Sabotage-confirmed both ways, and the pair is the interesting part:

  • Skipping the allocation walk turns the boot red and leaves the host tests green. Only the mkntfs image can see it.
  • Restoring the old offsets in the extracted walker turns test_ntfs_list red. That was a mistake actually made while pulling the walker out so the root and an INDX block could share it, and the synthetic fixture caught it on the first run — which is what that fixture is for.

exFAT mounts, and does not read a file, and says so

mkfs.exfat produces the image; exfatprogs ships no tool that writes into one without mounting it, and mounting needs root and FUSE — which neither a developer machine nor a CI container reliably has. NTFS is luckier: ntfscp writes into an image directly, so its marker is staged with no privileges and no host driver touching the artefact.

So the exFAT row carries no marker and the boot reports OK (no marker). Mounting a real mkfs.exfat volume and reading its root is far more than that driver had ever done; folding it into plain OK would claim a byte comparison that did not happen, and a gap that is visible is worth more than one that is not.

Two smaller things this turned up

A silent refusal that named the wrong cause. LOOP_MAX was 2. The third and fourth images were refused, and every refusal in vibeos_x86_64_loop_attach was a bare -1, so the caller reported the likeliest one: no image on this medium. For a boot that sentence was simply false — the images were on the disk. Every refusal now says which it was, and the gate tolerates only a genuine absence.

A field written on one path and read on all of them. The reported byte count was a single global, and the exFAT row — which never reads a file — printed the previous row's value. Cleared by the function that owns the contract now, which is the rule this project already wrote down after interp_base.

The wedge measurement, and what it actually found

Adding these two images raised the wedge rate: 2 wedges in 12 boots with four images against 0 in 12 with two, same binary, only the medium differing. The obvious explanation — more synchronous work during bring-up — does not hold: bring-up costs about 0.8 s more, and the wedges happen 45 s later.

Catching one said why it is not about filesystems at all:

[HW][TRAP] cpu=3 vector=0xd rip=0xdead0000dead0000 rsp=0x22d5d10 -> PANIC
[MM] POISON_BROKEN frame=0x22d5000 word=0x1a0 found=0x22d5d10 owners=0

A core executed the free-page poison pattern, and the frame the poison detector names is 0x22d5000 — which is the CR3 of another core in the same wedge report. A task running on an address space whose top-level page has been freed. That is the silent-wedge family this project has documented, caught this time by the poison and the guard rather than going quiet.

freed_by symbolises to vibeos_isr_stub_table, which is not a plausible caller, so that field is not to be trusted as it stands — first question when a detector fires is whether the detector is right, and part of this one is not.

These filesystems did not cause that. They changed the timing and the allocation pattern enough to make it more likely, which is the only role they play in it. It is recorded here rather than in a filesystem commit because it belongs to the memory manager, and it is open.

H-011: an attribute length that wrapped the record open (fixed 2026-09-11)

ntfs_find_attr bounded an attribute with off + attr_len > size and ntfs_data_attr with at + attr_len > size, both in uint32_t. An attribute length from the volume wraps that sum: 0xFFFFFFF0 at offset 64 adds up to 48 and passes. The resident-value checks after it are correct only if attr_len is, so a value longer than the whole record was accepted.

What made that worse than a wrong read: the MFT record is a local array on the kernel stack in lookup, in read_at and in one more function, and read_at copies the resident value to its caller. The bytes after the array - whatever the kernel stack held - went to user space.

Every bound in both functions is now length > size - offset, once offset <= size is known. That is the rule for any length read from a volume: the sum wraps, the difference cannot once the offset is inside.

test_ntfs_attr_length_wrap edits record 9 of the host test image - a resident $DATA holding "inner" - to that length and a 4096-byte value, remounts, and looks the file up. Refusing it is a correct answer; if it is found, neither its size nor a read may exceed the record. Red on the old code, green on the new. It does not depend on what the stack happens to contain.

The finding asked for the same sweep through every filesystem parser, and it was done. Record walks with 8- or 16-bit lengths over small offsets - ext2 directory entries, ISO9660 records, NTFS index entries and run headers - cannot wrap 32 bits. The four read_at paths (exFAT, ext2, ISO9660, NTFS) use offset + len > node->size after establishing offset < size: that wraps only with a size declared within 4 GiB of 2^64 and an offset in that range, and then len is not trimmed. Much harder to reach, still wrong, and recorded in the review tracker to be fixed with M-009 and M-010.

Host tests green. check.sh all was red once: one of its three boots failed, and its log was overwritten by the next boot, because the check loop kept no evidence - so that failure could not be read. The loop keeps failed logs now. Twelve boots on the same kernel, every failure kept: 12 pass, 0 fail.

M-009, M-010 and the read-path audit: arithmetic from a volume (fixed 2026-09-11)

Three defects of one kind - a number read from the volume used in arithmetic that wraps or is undefined before anything checks it.

M-009, exFAT. A contiguous file's cluster was first + index in 32 bits, with first taken from the stream entry unchecked: 0xFFFFFFFE + 4 is 2, a cluster the volume has, and exfat_read_cluster only ever saw the wrapped value. exfat_nth_cluster now checks the first cluster against the volume and the index against the clusters left after it, before the sum. Found beside it: read_at truncated the cluster index to 32 bits, so a declared size past 2^32 clusters read the file's own start again; the index is computed in 64 bits and refused past 2^32.

M-010, NTFS. ntfs_next_run took each field size from a header nibble - up to 15 bytes - and shifted byte i by 8 * i into a 64-bit value, past the width of the type from the ninth byte. Sizes above 8 are refused. More than was reported: the sign extension -((int64_t)1 << (8 * off_size)) was already undefined at the largest legal size, and shifting a byte into the sign bit of an int64_t is undefined too; the offset is assembled and extended in unsigned arithmetic now, and the running start is summed unsigned as well, since a signed sum of volume-supplied deltas can overflow. The review's second candidate, vcn < seen + run.length in ntfs_map_vcn, is vcn - seen < length now, with the running total refused before it could wrap.

The read-path row from the H-011 audit. exFAT, ext2, ISO9660 and NTFS read_at trimmed with offset + len > size after offset < size; for a size declared near 2^64 the sum wraps and the length is not trimmed. All four use len > size - offset.

test_exfat_cluster_arithmetic_bounds reads a forged node three ways - a first cluster that wraps onto cluster 2, an index past 2^32 clusters, and an offset whose sum with the length wraps - and each must return nothing. test_ntfs_run_header_sizes lengthens BIG.BIN's run attribute and gives its first run a nine-byte length field; it must not decode. Both were red on the old code and are green. Two limits on what that red proves: the exFAT test stops at its first failing case, so the three cases were not each shown to fail on their own; and the NTFS defect is undefined behaviour, so its red shows what this compiler made of it, not what any compiler would. Before either ran, two test bugs were caught in review: the exFAT test would have overwritten the root directory it then read, and the NTFS test fitted twelve run bytes into an attribute holding eight, so the old "do the bytes fit" check would have refused the header and the test would have passed on the defect.

Host tests green, check.sh all green (three boots; the boot volume is FAT and these drivers are not on its path).

I2: one cache, not three

Status: done. Every sector the boot filesystem reads goes through kernel/fs/blockcache.c. The hit ratio is 93% and the boot gate asserts it as a ratio; twenty-four consecutive boots agree.

What was found first, and it is bigger than the phase

Nothing created a block cache on a booting machine. vibeos_storage_scan is defined and called by nobody; blockcache.c, exfat.c, ext2.c, iso9660.c, ntfs.c, partition.c and journal.c are host-tested and had never executed a line at boot. The whole of kernel/fs/ was in the state the swap stack was in this morning — built, tested, sabotage-verified, and never run — except that it is a subsystem rather than a phase.

So this is not an optimisation. It is what makes any of that code real.

The lock

Its own, by registration. The sixth layer here to need one and the reason has not changed: a layer with mutable state and more than one possible caller locks itself. Not the page cache's and not the frame layer's — those sit at different levels, and a lock shared across levels is exactly what would have deadlocked the page cache on its first miss, since a miss there allocates a frame.

One thing the lock had to move: the copy out of the slot. blockcache_read copied after fetch, and a slot can be evicted and refilled the instant the lock is released — which is the page cache's old defect wearing different clothes, a lookup that hits and hands back somebody else's bytes. The copy is inside now.

Write-through, deliberately, and only for now

The cache can do write-back and this does not use it. vibeos_x86_64_blk_read_many stays a direct bulk transfer, because reading a two-megabyte image one sector at a time through a cache would undo the multi-sector path — and that path is what turned a FAT read from something indistinguishable from a hang into an ordinary read.

A bulk read that bypasses the cache is safe only while the device is authoritative, which is precisely what write-back stops being true. So writes go to the cache and to the medium. Write-back belongs with I4, where writes are read back and proved; turning it on here would trade a verified property for an unverified one.

Two caches in series, and what that cost

The first measurement was 7%, on twenty-four consecutive boots, exactly.

fat.c still had its own one-sector FAT cache. It absorbed every repeated table read and passed on only the misses, so the block cache was being asked to serve the traffic that by definition could not be served. Two caches in series where the first is a hundredth the size of the second is not belt and braces — it is the small one deciding what the large one is allowed to see.

Removing it took the ratio from 7% to 93%: 220,375 hits against 14,616 misses. That is the whole content of the phase's name, and routing the calls without removing the old cache was doing half of it and getting the worse half of both.

The floor, and how it was got wrong first

Set to 20 from a single measurement of 38%. Twenty-four boots then measured 7% every time — the 38% came from a stale image.

That is the rule this project writes down and I broke while quoting it: check a criterion against the baseline before using it to judge a change. The floor is 50 now, chosen after seeing twenty-four boots at 93%, comfortably under a healthy boot and far above anything a cache that had stopped caching could reach.

It stays a ratio and not a presence check, for the reason the plan gives: the mm page cache shipped at 36% while "non-zero hits" passed happily.

Verification

repeat-boot.sh at 24, which is what the plan demands for this phase, because a cache's failure mode is a hit that returns the wrong data and that looks like a working boot until a program is handed somebody else's bytes.

21 of 24 clean — the same 87.5% this machine measures without the change (6/6, 7/8, 6/6 before it). Of the three failures, two are the bad-args execve refusal named yesterday and one is the known busybox_cat wedge. None is new.

A diagnostic that erased its own evidence

The two bad-args failures printed at=argv:- — no reason. hw_copy_user_argv cleared the reason on entry, and execve calls it twice: argv failed and set it, envp then ran, cleared it, and succeeded. The refusal printed what was left.

It is set on failure and never cleared now. Worth recording because the fix I shipped yesterday was specifically to stop this failure being silent, and it was still silent about the one thing that mattered.

I4c: writing a partition table

Status: the MBR writer is in, with four safety rules and a round trip on a scratch device. GPT writing and format are not done.

The plan's own section opens by saying this is the one phase that can lose a user's data, and asks for confirmation before starting. That was asked and given, and the shape of the answer is why there is a scratch device at all.

The four rules

A stale view may not overwrite a newer one. Every write states the checksum of the table it believes is on the disk and is refused if the disk holds something else. Without it, two things that both read the table and one of which writes silently discards the other's work — and here that work is where somebody's filesystem begins.

A partition in use is not touched. Mounted, or named by the swap area. The caller supplies what is in use rather than this layer guessing, because guessing wrong is the whole failure mode. Two mistakes are checked separately because they have the same consequence by different routes: a new table that overlaps a live range, and one that has simply dropped it.

Entries may not overlap, and none may start at sector 0. Sector 0 is the table; a partition starting there is overwritten by the write that creates it.

Sector 0 is edited, not rebuilt. It holds boot code as well as the table. A writer that replaced the whole sector would make a disk unbootable while doing exactly what it was asked.

GPT's backup-before-primary rule is stated in the header and not yet implemented, because there is no GPT writer yet. It is written down where the writer will go rather than in a plan file, so whoever writes it finds it.

The scratch device, and the plan it replaced

A second QEMU disk was the intended answer and it needs something else first: every piece of virtio-blk's state is a global — the queue, the descriptors, the request struct, the lock — so a second device means making the driver the machine boots from per-instance. That is a real refactor, and doing it inside a phase about writing partition tables would mean two risky changes verified by one result.

So the scratch device is RAM-backed and registered like any other driver: it gets a device number, goes through vibeos_blk_register, and is bounds-checked by the same code. The write path under test — the partition writer, the block layer, the cache — is the real one. Only the medium differs, and the medium is the part I4 already proves.

What that does not cover is a table surviving a power cut, which was never in reach of a single boot anyway. It is the same gap as I4 step 3.

The boot reports:

[IO] PARTTAB scratch_device=0x1 round_trip=OK

It writes two partitions, reads the sector back through the ordinary vibeos_partition_parse_mbr with no shared state, checks the entries, and checks that the 446 bytes the table does not own are untouched.

The sabotage that got through

Removing the guard overlap check did not turn the tests red. Two of the three guard cases fell through to the other check — "no new entry covers this range" — and were refused by that one instead, so the overlap check could be deleted and nothing noticed. One check was masking the other.

The case that catches it grows a mounted partition in place: the new entry starts exactly where the guard does and is larger, so the coverage rule is satisfied and only the overlap rule can refuse it. Refusing is right — a mounted filesystem has an opinion about where it ends, and changing that underneath it is how a volume comes to read past its own data.

It was found by running the sabotage and watching green, which is the third time this week that has been the only thing separating a working test from a decorative one.

Step 3: format

An empty FAT16 volume — boot sector, two copies of the table, a root directory of zeroes — written by the filesystem's own operation. The volume layer never learns what a FAT boot sector looks like, which is the rule the plan states and the reason this project has spent whole phases removing second places that have to be right about the same thing.

FAT12, FAT16 and FAT32 are the same header. A reader tells them apart by cluster count and by nothing else: under 4085 is FAT12, under 65525 is FAT16. So a formatter that picks its geometry carelessly produces a filesystem of a different kind from the one it intended, with every field still looking correct. This one solves for sectors-per-FAT — the table's size depends on the cluster count which depends on the table's size — and refuses a volume too small to reach FAT16 rather than quietly producing FAT12. The scratch device grew from 1 MiB to 4 MiB for that reason: sized by the format test, not the partition one.

No boot code is written. The first three bytes are a jump to nothing, which is what every tool writes for a data volume; doing otherwise would put a bootloader on a partition nobody asked to boot from.

[IO] FORMAT fs=fat first_lba=0x40 result=OK

Checked by probing, not by mounting, and the reason is the limit recorded in io_mounts.md: this driver's state is a single global, so mounting the scratch volume would unmount the one the machine is running from. Probing proves the bytes on the medium are a FAT16 volume a reader will recognise, which is what a format op is responsible for. The probe used is the one the volume scan uses, with no shared state with the formatter — a formatter checked by its own idea of what it wrote proves nothing.

What is left in I4c

Mount, write a file, unmount, re-read. Blocked on the same thing twice over: FAT's state is one global g_fat, with 77 references across 1200 lines. Making it per-instance is the change that unblocks both this and I4b's "mounts more than one", and it is a wide edit to the driver the machine boots from — so it belongs on its own, not folded into a phase about partition tables. Doing both here would be two risky changes verified by one result, which is exactly what the scratch device was chosen to avoid.

GPT writing, with the backup written before the primary. The rule is stated in parttab.h where the writer will go, rather than only in a plan file, so whoever writes it finds it.

A format that survives a reboot. The scratch device is RAM, so its cache and its medium are the same memory and an unflushed format is still there. Same gap as I4 step 3, and it needs the same thing to close: a medium that survives a boot.

I3 — a length is not a byte count

The conflation

hw_read_file_cached returned one number that meant two things: how long the file is, and how many bytes it put in the caller's buffer. They were equal only because it refused to proceed otherwise —

if (size > (uint64_t)cap) {
    return -1;
}

— a refusal about the buffer, applied to a file the cache did not need a buffer for. That single line is why execve carried six megabytes of staging: not because anything needed the bytes contiguously, but because this function would not run without room for all of them.

What changed

The loop still walks every page, because that is what populates the cache, and the cache is what the page fill and the interpreter path read through. What changed is that the copy stops at the caller's window and the return value keeps describing the file.

hw_proc_create now takes both numbers, and the signature says which is which:

static int hw_proc_create(hw_proc_t *p, const unsigned char *elf,
                          uint64_t len, uint64_t staged, ...)

One caller has a program built into the kernel and passes len for both. The other passes the window. Leaving a caller to assume they are equal is how they came to be one number in the first place.

The header extent, asked rather than recomputed

vibeos_elf_header_extent() says how far into a file the parser will reach for the ELF header and the program headers. A caller that stages only a window has to know whether its window is big enough, and the alternative to asking is duplicating the header layout at the call site — which is how two pieces of code come to disagree about where a program header table starts.

A file whose headers exceed the window is refused by name (headers_past_window), not truncated. Truncation would hand the parser a partial header table and it would describe a program that does not exist — the same shape as the short read that once made execve parse the previous program's bytes.

vibeos_fs_size was not needed: vibeos_fs_node_t already carries the file's size, so asking how long a file is has never required a buffer that could hold it. The conflation was one layer up.

The buffers are still six megabytes, and that is the interesting part

Nothing needs them. Setting both windows to 64 KiB works, boots, and runs every program on the media — and turns the gate red with mm_poison_hits=3019, deterministically, on every run.

It is not the shrink's defect:

  • shrinking either window alone is clean;
  • only both together fail;
  • and deliberately wasting the six megabytes the two shrinks free — allocating them at boot and never using them — makes the poison disappear while the windows stay small.

So what the shrink changes is which frames the allocator hands out, and something writes to a frame it has already released. The change reveals a latent defect rather than causing one.

Why this matters more than the six megabytes

This is the first deterministic reproduction of a defect this project has been chasing at roughly one boot in sixteen. Every previous investigation into the mappers=2 owners=1 family — four of them, three producing confident wrong answers — was fighting an event that appeared once in dozens of boots. This one appears 3019 times, on demand, every run.

The recipe is recorded in arch_hw.c beside the constant, where somebody changing it will read it: set both windows to 64u * 1024u.

The buffers stay large until the defect is closed, because six megabytes of kernel memory on a four-hundred-megabyte machine is a smaller cost than a gate nobody can trust.

Verified

  • rc=0, no warnings on gcc or clang, host tests and fuzz green, boot verified with the windows at their original size.
  • The shrink verified to fail, and verified to fail deterministically, which is what makes it useful.
  • The waste experiment verified to make it pass again, which is what makes the attribution sound rather than plausible.

Not done

The buffers do not shrink, so mm's P4 step 3 stays open — but its blocker has moved. It was "the read contract conflates two numbers"; it is now "there is a use-after-free that only shows when the allocator's layout changes", which is a defect with a reproduction rather than a design problem.


Correction

This file recorded the staging shrink as having produced a deterministic reproduction of the project's intermittent use-after-free. It had not. The 3019 poison hits were the frame layer's detector reading uninitialised descriptor flags, and the staging sizes mattered only because they decide where that descriptor table lands. The windows are 64 KiB now and the shrink this phase unblocked is done. See mm_frame_init_flags.md.

I4b steps 1 and 2: the disk says what is on it

Status: the scan runs and reports. Steps 3 and 4 — probing and a mount table — are not done, and the boot line says why.

What was already there

Everything. vibeos_partition_parse_mbr, vibeos_partition_parse_gpt with both CRCs checked, the protective-MBR recognition, vibeos_storage_scan — all written, host-tested, sabotage-verified, and called by nobody. The same state the swap stack was in yesterday and the block cache was in the day before, and for the same reason: nothing noticed.

So the work here was not writing a partition reader. It was making the one that exists run.

What the machine now says

[IO] VOLUMES table=mbr partitions=0x1 volumes=0x1 mounted=0x0 disk_sectors=0xfc000
[IO] VOLUME idx=0x0 first_lba=0x3f sectors=0xfbfc1 kind=fat fs=none

An MBR-partitioned disk of 504 MB, one partition starting at LBA 63, recognised as FAT. And fs=none.

That last field is the finding. The volume is FAT and no driver claimed it, because the portable driver set is exfat, ext2, ntfs and iso9660 — and none of them is the filesystem this machine actually boots from. The FAT driver lives in kernel/arch/x86_64/fat.c, outside the vibeos_fs_ops_t world the scan mounts through.

So the machine can now read its own partition table and cannot mount what it finds there, which is a much more precise statement of the gap than "only FAT is mounted" was.

It reads through the one cache

Not a second one. vibeos_x86_64_fat_cache() hands out the instance the filesystem already uses, because two caches over one device is exactly the arrangement I2 spent a phase removing — and standing a second one up here to avoid a two-line accessor would have put it straight back.

Three dead counters came alive

volumes_found, mounts and probe_rejected were on check-counters-produced.py's exemption list as "I4b, volumes and probing". They are produced now, and the check failed on the stale exemptions — which is the half of that check that is easy to overlook: an entry left there after it stops being true is a permission slip lying around for the next counter that goes quiet.

What is not asserted, and why

mounted is not required to be non-zero. Demanding a mount here would be demanding a driver that does not exist, and a gate that fails for a reason nobody can act on is one people route around. The volume count and the disk size are asserted: a scan that found nothing, or one that saw a disk of unknown size, is broken rather than merely unlucky — a size of zero makes the scan decline to parse a GPT at all, since every one of its checks is against a size.

The case this environment cannot run

Accepting the protective MBR as a real table is the most dangerous defect this layer can have, and this boot cannot reach it: the medium is MBR-partitioned. A GPT disk parses as a perfectly valid MBR describing one partition covering the whole disk, so a reader that tries MBR first and stops on success gets a plausible wrong answer rather than an error — and nothing reports it. The host tests cover it; the case file records that end-to-end reproduction needs a GPT image, in the same spirit as the AHCI cases QEMU cannot exercise.

I4 step 2: a write that is checked

Status: done and gated. A boot writes a file, reads it back and compares it byte for byte. Steps 1, 1b and 3 — write-back, the barrier, and the across-reboot check — are not done.

Why this before the mechanism

The plan lists write-back first. It is done second here, and the reason is in I2's own note: the block cache was left write-through deliberately, "because turning write-back on would trade a verified property for an unverified one, and write-back belongs with I4, where writes are read back and proved". Turning it on before the proof exists would be doing exactly that. So the proof lands first and the mechanism gets checked by it.

What it checks

A 1300-byte file, written, read back, compared.

Every byte carries its own offset. A constant survives every interesting failure this can have — a write that landed a sector early, a read that returned a neighbouring sector, a transfer that lost its last sector and left the previous contents — because all of them hand back bytes equal to what was expected. An offset-dependent pattern fails all three and says where.

1300 is not a multiple of 512 on purpose. A file of exactly N sectors never exercises the tail, and the tail is where a length confused with a byte count shows up. This project has had that defect once already, in the FAT reader that returned the size the directory claimed.

The buffer is cleared before the read, so a read that returns nothing at all cannot pass by leaving behind what was just written into it.

What it does not prove

That the bytes reached the medium. Everything here could be served from the block cache; with I2's write-through policy the device was written too, but this check cannot tell the difference. Only a reboot can, and that is step 3.

Three things it found immediately

The write refused, and the refusal said nothing. fat_write_file_locked had seven return -1s: a full disk, a name that is not 8.3, a directory with no free slot and a medium that would not take the sector all arrived at the caller as the same value. They name themselves now.

The parent did not exist. The first version wrote to DOCS/, and at mount time the root holds exactly EFI and STARTUP.NSH — the docs directory staged on the host is not presented to the guest at all, and DOCS/NOTES.TXT exists later only because the boot script creates it. The check was wrong about the medium, not about the kernel, and the refusal now says parent_directory_not_found rather than the earlier path_or_name_refused that sent the investigation looking at the name.

An [EXEC] line was assembled without bracketing, and CI caught it before this was committed:

[EXEC] interpreter EFI/BOOT/LDMUSL.SO[HW][SYS] write(ring3): SVC_EXIT svc-stress 10 0

Five serial_puts calls are five critical sections, and that one runs inside execve while services start on other cores. The boot gate's interleaved_lines check failed the run — which is the check working exactly as designed, since a cut line can invent a failure or hide one.

A scan for its siblings reported forty-nine more. Most are false positives: the scanner treats an unlock in an early-return branch as ending the critical section, so a correctly bracketed block like the [CRASH] dump reads as unbracketed. That is why the forty-nine were checked rather than fixed — a blanket edit of forty-nine sites on a bad number would have been the expensive kind of confident wrong answer.

Confirmed by breaking it

Skipping the data-sector writes while still returning success:

[IO] WRITE_PROOF first_bad_offset=0x0 got=0x30 want=0x5a
reason=invariant_failed:write_proof:FAILED:_contents_differ

That is the failure the phase is named for: a boot writes about thirty sectors through the shell's mkdir and nothing checked any of them, so a write path that acknowledged without writing was invisible until some later boot could not mount.

And the checks caught their own author

check-assertions-covered.py, written earlier the same session, failed on write_proof_missing the moment the assertion was added — before the phase was committed. The case was written; the count went back to its baseline. That is the whole of what that check is for, working on the first new assertion after it existed.


I4 steps 1 and 1b

write_many. Both drivers already had the multi-sector machinery — virtio_blk_rw_n and ahci_xfer each take a write flag — and neither exposed it, so the block layer's adapter wrote a run one sector at a time. The read side learned that lesson expensively: a 2 MiB transfer done a sector at a time under a lock that masks interrupts was indistinguishable from a hang and was reported as one. There was no equivalent on the write side only because until now nothing wrote enough to notice — a boot's entire writing is about thirty sectors.

The barrier. vibeos_blk_barrier(device) is not a flush, and the distinction is the point: a flush empties a cache, this only orders. They belong to different layers — the block cache above is emptied by vibeos_blockcache_flush, and what this does is tell the device to stop holding writes in its own volatile cache. A real drive acknowledges a write as soon as it reaches that cache, so a caller that has written and not called this has no ordering at all.

virtio-blk issues VIRTIO_BLK_T_FLUSH, which carries no data — the chain is the header and the status byte with nothing between them, so it could not be built out of virtio_blk_rw_n. AHCI issues FLUSH CACHE EXT, a command that had been defined in that file from the beginning and never issued: one more of the declared-and-unused family.

A device with no barrier is refused, not quietly granted. A null hook answers NO_DEVICE. A caller told "yes" would build on an ordering that is not there, which for a journal is the difference between a recoverable medium and a corrupt one.

The write proof asks for one before reading back, so the primitive is exercised on every boot rather than merely existing, and the gate asserts both that it was asked for at least once and that none failed.

What is left in I4

Step 3, the across-reboot check — the only test that separates a write that reached the medium from one that reached a cache. It needs a medium that survives a boot, and QEMU's synthesised FAT is not one: the guest does not even see the docs directory staged on the host. That makes it a job for the real disk images and VirtualBox rather than for the boot gate.

Write-back in the block cache is also still off. The policy note in I2 said it belongs here, and it does — but it belongs after step 3, because write-back is precisely the thing that makes "the bytes are on the medium" stop following from "the write returned". Turning it on while the only check is within a single boot would remove the property the check is testing.

IPC Subsystem Progress

Status: In Progress Last review: 2026-08-26

Implemented

  • Event and bounded channel primitives in kernel/ipc/event.c and kernel/ipc/channel.c.
  • Handle-transfer path with rights reduction in kernel/ipc/handle_transfer.c.
  • Waitset core plus ownership enforcement in kernel/ipc/waitset.c.
  • Multiple wake policies (FIFO, REVERSE, ROUND_ROBIN, PRIORITY).
  • Per-event metadata controls (priority and enable/disable).
  • Batch wait, wait-all, and signaled peek helpers.
  • Extended telemetry for wait behavior (disabled skips, priority wakes, wait-all, batch, peek).
  • Scheduler-aware thread wait wrappers with wake CPU feedback.
  • Contention snapshot API (vibeos_waitset_contention_snapshot) with enabled/signaled visibility for load diagnostics.
  • Handle transfer rejects invalid source handles before allocating anything; valid generic handles remain transferable with reduced rights. Host regression covers invalid-source rejection.
  • Maximum fan-in waitset coverage exercises all 32 event slots, contention snapshots and batch wake selection under round-robin policy.

Pending

  • Large fan-in waitset sharding strategy beyond the current bounded 32-slot primitive.
  • Fairness proofs under mixed policy workloads.
  • Stronger IPC load/perf benchmarks with reproducible profiles.

Next checkpoint

  • Add reproducible throughput benchmarks and a sharding design for waitsets larger than 32 events.

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

Linux Compatibility Progress

Status: In Progress (static, position-independent and dynamically linked Linux binaries all run end to end) Last review: 2026-08-27

Read this first: there are two things called "Linux compatibility"

They are unrelated, and confusing them is how this area went untracked.

  1. The kernel's Linux syscall layer (kernel/arch/x86_64/arch_hw.c, the LSYS_* dispatch). This is the real one. It is what runs a static musl program and a static BusyBox that this project did not build, and it is gated on every boot.
  2. The portable compatibility scaffold (user/compat/). A runtime object with per-target enable flags, translated/denied counters, and one translation function per foreign OS. The Linux translator maps two syscall numbers. It is a shape, not a subsystem.

Everything that works today is (1). The kernel does not call (2) to run a Linux binary; it enables the Linux target in the scaffold at boot and reports its counters, which is why the serial log shows small numbers next to a working BusyBox.

Implemented

  • 67 Linux syscalls in the LSYS_* dispatch, covering process lifecycle (fork, vfork, execve, wait4, exit_group), files and descriptors, pipes, signals (rt_sigaction, rt_sigprocmask, rt_sigreturn, kill, tgkill, tkill), memory (mmap, brk), sockets, and the startup interrogation a C runtime performs before main.
  • A System V startup stack with a populated auxiliary vector, including AT_RANDOM - absent, it is a null pointer dereferenced while seeding the stack canary, before main runs.
  • Copy-on-write fork, signal delivery through the libc's own SA_RESTORER trampoline, and pipelines in BusyBox's shell.
  • An unimplemented syscall is reported by number on the serial line rather than silently returning zero, so a program failing for that reason says so.
  • Dynamically linked programs run. A position-independent executable is placed at a bias chosen by the loader, and one naming a PT_INTERP has that interpreter mapped into the same address space and entered instead of the program. The interpreter relocates itself from AT_BASE and jumps to AT_ENTRY, which stays the program's own entry. Gated: DYN_OK and PIE_OK both fail the boot if they stop appearing.
  • The interpreter path is translated. A musl binary asks for /lib/ld-musl-x86_64.so.1, and the boot volume is FAT, which has neither that directory nor a name that long; the loader sits beside the other programs and the one path is substituted in the kernel. That is a stand-in for a filesystem layout, not a design.

Known sharp edges

Recorded because each cost hours and none is visible from the code:

  • Arguments typed int arrive zero-extended: mov $-100, %edi delivers 0x00000000ffffff9c, so comparing all 64 bits against AT_FDCWD never matches. Read them through VIBEOS_ARG_INT().
  • Linux sigset_t bits are numbered from zero - bit 0 is signal 1. This kernel numbers them by signal, and converts only at the boundary.
  • A wait status is not an exit code: exit code in the high byte, signal in the low seven bits.
  • /proc/self/exe must be absolute. glibc does not merely prefer it; it asserts and aborts during startup.

Pending

  • Dynamic executables were the largest gap here and are now closed; what is written under Implemented above covers them. What remains unproven is breadth: one program and one interpreter have been run, not a library with dependencies of its own.

  • Threads work, and are gated. clone(CLONE_VM|CLONE_THREAD) creates a task sharing the caller's address space with its own kernel stack and TLS base; futex waits and wakes for real; exit clears the word a joiner sleeps on - before any context switch, because the word lives in the dying task's address space - and only tears that space down when the last thread of the group leaves. Four threads share a counter under a contended mutex, land on exactly 8000, and each sees its own thread-local. Sixteen boots out of sixteen with that running.

  • getrandom is deliberately ENOSYS. AT_RANDOM is supplied; a real entropy source is a separate promise and has not been made.

  • Process groups and a controlling terminal, so ioctl(TCGETS) can be something other than ENOTTY and Ctrl-C can target a job.

  • The scaffold in user/compat/linux/ translates two syscall numbers and is not on the path any real binary takes. Either it grows into the portable front end for the kernel layer, or it should be deleted; leaving it looking like the implementation is the current cost.

Next checkpoint

  • Decide the scaffold's fate, then start M13: PT_INTERP and a loader, which is what turns "static binaries" into "binaries".

A spinlock that gives up, and what it ruled out

Status: the bound is in and gated by a sabotage case. It did not find the defect it was built for — and that is the useful result.

Why

A spin loop that never gives up turns a deadlock into silence, and silence is the most expensive failure this project has. The machine stops, the log stops, and what is left is a wedge report naming whichever static function happens to precede the spin address. In this one-translation-unit kernel that name is frequently wrong: both wedges examined this week reported hw_task_slots, a function whose entire body is return VIBEOS_HW_MAX_TASKS;.

So hw_spin_lock counts, and past a bound that no critical section here comes close to, it panics — which parks every core and prints a backtrace — naming the lock, the waiting core and the function it is waiting in, the holding core and the function that took it.

The names are captured at acquisition rather than resolved from addresses, because a confidently wrong name is worse than a number. hw_spin_lock_named takes __func__; the 34 acquisition sites pass it automatically.

The owner fields are cleared before the release, not after: a core taking the lock the instant it is freed would otherwise have its owner overwritten by the previous holder's tidying, and the report would name the wrong one. Same shape as the exit ordering this kernel already carries a long comment about.

What it ruled out

The svc-press wedge was the reason to build this: at twenty-eight megabytes of anonymous mmap the machine went quiet, and four cores sitting in pause loops looked exactly like a deadlock.

It is not a deadlock. The bound never fired. Under the same workload the machine now reports a ring-3 page fault instead, and the wedge is a consequence of that fault rather than a lock nobody released.

Two more things the same run ruled out, from a temporary trace of every anonymous mmap:

  • Not memory exhaustion. Free frames went from 104,572 to 98,735 of 107,485. Reclaim never ran once — scans=0.
  • Not the reverse-map pool. nodes_peak grew linearly at 64 nodes per 256 KiB block, reaching about seven thousand against a pool of two hundred and fifteen thousand.

What is left, and a wrong turn worth recording

The first version of this file said the remaining lead was a ring-3 page fault at rip=0x800000013d, a program executing bytes before its own .text.

That fault was not svc-press. It is svc-crash, the service whose entire purpose is to dereference null so the boot can prove the kernel survives a crashing program, and it appears on every healthy boot. The identical fault shows up with svc-press not running at all, which is what settled it, and the crash record says so outright: exe=/EFI/BOOT/SVC_CRSH.ELF.

CLAUDE.md names this exact mistake - "check which program the task was running before believing an address" - and it was made anyway, on a project where every VibeOS-native program links at the same base. The crash recorder existed the whole time and answers it in one line.

So the honest state of the svc-press wedge is that four explanations are now excluded and none has replaced them:

  • not a deadlock;
  • not memory exhaustion;
  • not reverse-map exhaustion;
  • and no fault of its own - it goes quiet with nothing at all.

A second calibration was needed before the first of those could be claimed. The bound is four hundred million spins, and the boot gate gives up after forty-five seconds of silence: under TCG the bound might simply never have been reached, in which case "it did not fire" would mean nothing. Rebuilding with twenty million - reachable well inside the gate's patience, since virtio's two-million bound fires there routinely - and rerunning svc-press produced no deadlock either. That is what makes the exclusion real rather than a guess about timing.

A log that contradicted itself

Found while reading the same output. The trap dump printed the trap model's decision, and the model has no notion of privilege level, so it answers PANIC for a page fault. A branch a few lines later overrides that for a ring-3 fault and kills the task instead. So a boot that correctly killed one program logged action=PANIC and then, on the next line, killing task, not the machine.

Two statements, one false, and the false one is the one a reader reaches first

  • which is precisely how action=PANIC was read as "the machine died here" for a whole investigation. The override is decided before the line is written now, and the model's answer is kept alongside it as model=PANIC, because a fault the model would have panicked on and one it would not are different situations.

macOS Compatibility Progress

Status: Not started (a two-entry translation table, no runtime) Last review: 2026-08-27

What exists

user/compat/macos/macos_compat.c, twenty-four lines, the same shape as the Windows one: a syscall-number translation function knowing two entries, with placeholder targets, incrementing a denied counter for everything else. Nothing calls it at runtime.

Tracked here so that a file existing does not read as work having happened.

What "started" would require

  • Mach-O loading, and with it fat binaries and two live architectures.
  • The BSD syscall layer and Mach traps - ports, tasks, threads and IPC are not a translation of anything in this kernel; they are a different model of what an operating system is.
  • dyld and the shared cache. macOS binaries are dynamically linked as a rule, and the system libraries they need are not distributable, which is the constraint that has no technical answer.

Pending

  • Everything above. The docs have long said macOS support is deferred "due to complexity and ecosystem constraints"; the second half of that phrase is the operative one, and it is a licensing problem rather than an engineering schedule.

Next checkpoint

  • None. This area is deliberately dormant and should stay that way until the native system and the Linux layer are considerably further along.

Memory Manager Progress

Status: In Progress - P0 and P1 done, P2 written but not certified: P2 and P3 done: a process now records the regions it asked for, and munmap and mprotect consult that instead of walking page tables. Three concurrency defects found and fixed in P2 - publication order, release order, and two cores resolving one fault. The residual boot flakiness turned out to predate the whole rewrite and is tracked in boot_repeatability.md. Details in the plan. Physical frames have one owner, in one file. Address spaces record what they own in the page-table entry, so nothing infers ownership from permission bits any more - which is what the premature-free family came from. No page-table write and no frame reference taken outside kernel/mm/, checked on every build. Last review: 2026-08-30

Implemented

  • Early physical memory manager initialization in kernel/mm/pmm.c.
  • Boot memory map interpretation and usable-range selection.
  • Initrd overlap avoidance logic during PMM bootstrap.
  • Page allocation primitive for early kernel/runtime test path.
  • Deterministic multi-page contiguous allocation primitive (vibeos_pmm_alloc_pages) plus allocation footprint introspection.
  • x86_64 runtime frame allocation now obtains page frames from the PMM for task and address-space bring-up rather than relying solely on static test storage.
  • Frame reference counting, which is what makes copy-on-write safe. One byte per frame over the allocator's region, taken out of that region at boot. Zero means "one owner", so the ordinary unshared case costs no bookkeeping at all and only sharing writes to the table. The count saturates at 255 rather than wrapping: past that a frame is simply never reclaimed, which leaks a page instead of freeing one somebody is still using. Address-space teardown and the copy-on-write fault both drop a reference and free only when they were the last owner.
  • Copy-on-write accounting. g_cow_shared and g_cow_copied are printed at the end of the boot self-test as [MM] COW_STATS shared=... copied=... - 1221 shared to 24 copied on the boot this was written against. The report exists because a mechanism that is never exercised and a mechanism that is broken look identical from outside. No gate asserts the numbers; what is gated is that forking and exec'ing programs keep working.

Virtual memory, folded in from virtual_memory.md

That file was a second account of the same subsystem and the two had already contradicted each other once, so it is gone and this is the only one. What was still true of it:

  • The kernel runs on page tables it built. Four levels, the first 4 GiB identity-mapped with 2 MiB pages, CR3 loaded with kernel-owned tables rather than the firmware's. A non-identity mapping is written through and confirmed translated, so this is arbitrary virtual-to-physical control and not an equivalent identity swap.

  • Copy-on-write fork on those tables. fork rewrites each writable user leaf read-only with PTE_COW in both address spaces and raises the frame's reference count; a write takes a fault that resolves by taking the permission back when this process turned out to be the only owner, and by copying otherwise. A page that was never writable is shared as-is - marking it copy-on-write would turn a genuine protection violation into a silent success.

  • Both user windows. The high one VibeOS programs link into, and the low one a Linux ET_EXEC image lives in, inside the kernel's identity map. A fork that skipped the low window would hand the child an address space with no program in it. Phase P2 makes this one implementation chosen by address rather than two functions a caller has to pick between.

  • Cross-core TLB shootdown, sent only to the cores running the address space being changed, because syscall clears IF and a broadcast times out against cores sitting in a system call.

What that file listed as pending is now the plan in docs/mm/: demand paging and a page cache are phase P4, and the portable model becoming the live authority is what P1 and P2 are doing. kernel/mm/vm.c remains an early portable sketch with no runtime role; kernel/mm/vmspace.c is the one being built to replace it.

The address-space layer (rewrite phase P2)

This is the phase that repairs the defect - and it is not finished.

A 48-boot run came back 27 clean. Everything below describes code that is written, host-tested, sabotage-checked and green on a single boot; none of that is the criterion, and the criterion says no. The failures are a wedge in userland and one run where the stress service reported a defect. What they are not is the old premature-free family: FREE_WHILE_MAPPED is silent, the three must-be-zero counters are zero, and the torture run finds no disagreement with its model. Step 2 alone was 16 boots out of 16, so the cause lies in the five commits after it.

kernel/mm/vmspace.c is the only code that writes a page-table entry. VIBEOS_PTE_OWNED - bit 11 - is set by vibeos_vmspace_map and by nothing else, and teardown, munmap and fork act on exactly the entries carrying it.

What that replaces is four generations of inference. The question "does this address space own this frame?" was answered by looking at permissions: is the entry present, does it carry PTE_USER, does it sit in the low window. Every one of those is a statement about access, and each was wrong in a different case:

  • A PROT_NONE guard page has no PTE_USER and is owned. Selecting on that bit leaked a thread stack per thread, and made fork hand a child of a threaded process an address space with holes where its guards belonged.
  • An identity-split entry is present and writable and belongs to the kernel. Selecting on that freed the kernel's own page tables at teardown, and the machine stopped with no output at all - no panic, because there was no kernel left to fault from.

The answer is written down now, at the moment it becomes true, in the same word of memory as the mapping it describes. There is nothing left that can drift.

The compiler helps keep it that way: hw_page_get became unused the moment the mapping functions moved, because taking a reference on a user frame now happens in exactly one place. scripts/dev/check-mm-layering.sh runs on every build and fails if a page-table write or a vibeos_frame_get appears outside kernel/mm/.

Two bugs fixed rather than moved, both reachable from an ordinary program:

  • mprotect granted write access to a copy-on-write page on request. Such a page is read-only because it is shared, so this let a forked process write straight into a page its parent was still reading. It now stays read-only; the write faults, the fault copies, and the program gets what it asked for one fault later.
  • fork skipped PROT_NONE pages in the low window. Fixing it raised cow_shared from 0xbd2 to 0xc09 per boot - fifty-five pages that had never been cloned at all.

And one nearly shipped. The plan specified bit 9 for the ownership mark; PTE_COW is 0x200, which is bit 9. Every mapped page would have read as copy-on-write, so a write fault on a genuinely read-only page would have been resolved by granting the write. Sixteen clean boots showed nothing. A _Static_assert now makes the two bits provably different.

And one wedged the boot. Moving the copy-on-write fault into the layer meant it allocated a frame directly, and until then every allocation had gone through an architecture wrapper that took the memory lock. Two cores resolving a fault at the same moment corrupted the free list. The lock lives inside the frame layer now: a layer several cores can drive has to defend itself, because "remember to hold the memory lock" is not a property a compiler checks - and it held for exactly one phase.

Fifteen sabotage cases in scripts/dev/cases/mm-vmspace.txt, all confirmed red, scored on the host in about a second each rather than by booting.

The frame layer (rewrite phase P1)

Everything in the section below describes the scheme this replaced. It is kept because the reasoning is still the reason the new one is shaped as it is.

kernel/mm/frame.c is now the only code that owns a free list or a reference count, and it is host-tested: allocation, sharing, double release, a release of something the table does not describe, poison verified on reuse, reservation, contiguous allocation broken by a reservation, and a never-freed frame that must not be reported as corrupt. Each was confirmed to go red by breaking the code it protects.

What changed in substance, rather than in location:

  • An allocation owns its frame (decision D9). A caller that allocates in order to map hands the frame to the address space and lets go; a caller that allocates for the kernel holds the only reference. Releasing the last reference and reclaiming the frame are one operation, so there is no longer a moment in which a frame has no owners and is not yet free - the state every defect here has lived in.

  • The free list moved out of the pages. It used to be a pointer in the first word of every reclaimed page, so the poison could not cover the whole page and the check had to skip the first two words. The list is threaded through the frame descriptors now; a freed page is poison end to end.

  • The bootstrap allocator is closed, not merely unused. It was still serving the GUI back buffer after the frame layer had adopted the region - several megabytes the frame table described as free and handed to a process, with the desktop rendering over its memory. No detector fired: nothing had been freed early, the frames were given out twice. That is the failure mode of having two allocators, and it is why closing it is a correctness rule.

  • meminfo is one picture. The states partition the total exactly and the boot gate fails if they do not. The same walk reports largest_free_run, so free memory that is too scattered to satisfy a contiguous request is a number somebody can read rather than an allocation failure nobody can explain. Compaction itself is phase P6.

Frame ownership, redesigned (the scheme P1 replaced)

The reference count now means what it says: the number of address spaces that map a frame. One owner is 1, nobody is 0, and a frame is freed at 0. The previous scheme counted "owners beyond the first", so zero meant one owner and every path carried a mental offset of one - and an untracked frame answered "yes, free it", so a missed increment produced silent corruption rather than an error. That default is reversed: a frame the table cannot describe is never freed. A leak can be measured; freeing a page somebody is running on cannot.

Counting happens where mappings are made and unmade - hw_map_page, hw_map_low_user_page, and the paths that clear a user PTE - rather than being the caller's responsibility to remember.

Two mistakes made during this change are worth keeping, because both were caught by the detector rather than by reading:

  • Counting on PTE_USER missed PROT_NONE mappings, which a C library uses to reserve a thread stack and only later makes user-accessible with mprotect. 211 frames per boot were reported as having no owners.
  • Then releasing on PTE_PRESENT in the low window released the 511 identity entries left behind when a 2 MiB leaf is split - kernel memory, never counted, but pointing at frames inside the allocator, so the release decremented other people's counts.

Still open. hw_aspace_destroy occasionally frees a frame that another live process maps, about three boots in sixteen, and the count agrees it was the last owner - owners_after_put=0. So the count is consistent with itself and wrong about the world: something maps a frame without the count knowing. No leaf write outside the two mapping functions accounts for it, which points at shared page tables rather than shared leaves.

The deeper answer is that there is no single owner of a mapping. Address spaces are built in hw_proc_create, hw_aspace_copy_user, hw_aspace_copy_low_user, the copy-on-write fault and mprotect, and torn down in two places with different rules for the two windows. Counting was added to two of those; making it reliable means giving mappings one owner, not more counters.

Superseded by a rewrite

The arrangement described above is being replaced rather than repaired. One defect - a frame released while another address space maps it - has survived four fixes, because ownership is inferred from hardware bits rather than recorded. See ADR-0007 and the plan in docs/mm/.

Work proceeds in phases P0 to P7; P2 is the phase that repairs the defect. Nothing in this file is being changed in the meantime, so that it remains an accurate description of what is running.

Pending

  • A gate on the copy-on-write counters, so a fork that silently went back to eager copying would fail the boot rather than be noticed by someone reading the log.
  • Buddy or segregated allocator for long-lived runtime.
  • Zone policy (DMA/normal/highmem-like partitions where applicable).
  • Allocation diagnostics for fragmentation and pressure trends.
  • Ownership for frames shared by anything other than fork: file-backed mappings and DMA are not refcounted, because neither exists yet.
  • A refcount wider than a byte, so a heavily shared frame is reclaimed rather than deliberately leaked at 255 owners.

Next checkpoint

  • Add refcount diagnostics and memory-pressure stress coverage before replacing the current allocator backend.

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.

Memory Manager (as of 2026-09-29)

Rewrite underway (ADR-0007). P0-P3 done; P4 done: the staging buffer is no longer a requirement and has shrunk from 6 MiB to 128 KiB - what held that up turned out to be vibeos_frame_init leaving the descriptor flags uninitialised, so the poison detector reported 3019 use-after-frees in a boot with none (mm_frame_init_flags.md); P6 done (reverse map audited every fork, watermarks and pinning wired, compaction with a fragmentation test); P5 now runs on the machine: an 8 MiB contiguous file on the boot volume gives swap 2048 slots, the boot writes a page through the area and finds it again by reading SWAPFILE.BIN through the filesystem, and the fault handler brings a swapped page back - what is still unproved is the eviction path itself, since freed_anon stays zero on a machine nothing runs short of memory (mm_swap_file.md); P7 done: the soak is proved - 26 frames lost at 120 stress rounds and 28 at 12000, so the loss is a fixed cost rather than a per-fork leak. Getting that number honestly meant fixing the machine: the wait for userland to finish treated a blocked task as retired, so the accounting was being sampled mid-fork (mm_soak.md). latency is proved too: every wait a syscall can reach is bounded, each bound now increments a counter, and the gate asserts them - VIBEOS_BLK_TIMEOUT was asserted by the gate and produced by no driver, so that assertion was green and could not go red (mm_latency.md). P7 is done; what remains for the plan as a whole is exercising the eviction path under real memory pressure. Physical frames have one owner in kernel/mm/frame.c; address spaces record what they own in the page-table entry (kernel/mm/vmspace.c), so nothing infers ownership from permission bits any more. No page-table write outside kernel/mm/, checked on every build. Host tests, 150 torture seeds and single boots are green; the repeat-boot criterion is not, and closing that comes before P3. Plan in docs/mm/; current state in memory_manager.md

P7 — the acceptance properties

The phases above build the memory manager; this one is meant to prove it is ready. What makes these tests different from every other test in the subsystem is that each exercises a path an ordinary run never reaches: the unwind after a failed allocation, the state of a machine that has run out, the contents of a frame handed to its second owner. None of those happen on a boot that goes well, so none of them had ever been executed until something went wrong in front of somebody.

What is proved

Exhaustion. Allocate until the allocator refuses, then give everything back and check the machine is where it started. The property is not that allocation fails — it is that nothing changed. An allocator leaking one frame per refusal looks identical from outside until the leak matters, and by then there is nothing to point at.

The test also re-allocates everything afterwards, and that half was added because a sabotage case walked through the first version. The free count and the free list are separate: a release that decrements one without linking into the other leaves a machine that believes it has memory and cannot hand any out. "The count came back exactly" was true and meant nothing.

Isolation. A frame's second owner never sees the first one's data. The fake RAM is filled with 0xCD rather than zero, deliberately — on zeroed memory the test passes whether or not the kernel clears anything.

Fault injection, and invariant I5 proved rather than asserted. vibeos_frame_fail_after(n) refuses the nth allocation from now. Building an address space and mapping a page allocates several times — tables at three levels, then the frame — so sweeping n exercises a different unwind path each time, and after each one the free count must be exactly what it was.

A count rather than a rate, because "the third allocation of this operation" is reproducible in a way that "one in twenty" is not. And the sweep ends by checking that the injection fired at all: a sweep that never triggers passes every assertion while proving nothing, which is the failure this project has hit repeatedly in the last few phases.

The hook is compiled in, deliberately. The alternative is a build the tests cannot reach, which is how a subsystem comes to have unwind paths nothing has ever executed — the exact situation this phase exists to end.

No mapping survives a failed address space. The reverse map is the one structure that could keep a record of a mapping whose page tables have been freed, and a stale holder there is worse than a leaked frame: compaction would follow it and write into memory that now belongs to something else.

What is not proved here

Soak, half of it. The plan asks for the stress service with its round count raised by two orders of magnitude, and frames_free matching its value at USERLAND_START. The second half is now asserted on every boot; the first still needs a boot budget this gate does not have.

The arithmetic is: free on the way in, against free on the way out plus what the page cache is deliberately holding. A boot leaves twenty-six frames unaccounted for, and that number has been stable across every boot measured.

It is asserted as a ceiling of sixty-four rather than as equality, and the distinction is the point. Twenty-six is a known open defect, not a tolerance anybody is comfortable with; the ceiling exists to stop it growing rather than to bless it. Equality would fail on every boot, and a check that fails on every boot is a check people route around — the same reasoning as asserting the cache as a ratio rather than as "not zero".

A negative result is refused outright: frames appearing means something was released twice, which is a different defect and never acceptable.

Verified by leaking one frame in sixty-four during teardown, which gives userland_frames_lost=80_ceiling=64. A leak of one frame per fork is invisible in any single figure — meminfo looks healthy, the totals still partition — and shows up hours later on a machine with no event to point at. This is the check that would catch it on the boot that introduced it.

Latency. "No path reachable from a syscall waits for another core without a bound." Several bounds exist and are asserted — the TLB shootdown's acknowledgement count and timeout, virtio-net's transmit bound, the console lock's bad-unlock count — but there is no systematic sweep of every wait, and claiming one would be claiming more than has been done.

Verified

Four host-test groups and three sabotage cases confirmed red, with a fourth recorded in scripts/dev/cases/mm-acceptance.txt as behaving differently from expected: it turns the suite red by segfaulting an earlier group rather than by failing an acceptance assertion. That is written down rather than counted, because "the suite caught it" and "this test caught it" are different claims.

One test was badly written first and is worth remembering: the isolation check asserted per byte with an empty message, so a failure printed four thousand blank lines. A test whose output cannot be read is a test nobody will use.

brk and mmap gave memory back to nobody

Two findings from an external review, both verified against the source before being believed, both on paths a ring-3 program reaches.

brk shrinking removed the region and stopped there

The shrink branch called vibeos_vma_remove and nothing else. The page-table entries stayed present and the frames stayed allocated, so memory a program had handed back to the kernel was still mapped and still readable through the pages it had just released.

The region list and the page tables are the two sources of truth about an address space, and this left them disagreeing - the same shape as the defect that let munmap free frames belonging to somebody else.

Removed from the list first, then unmapped: a region must never be described after it has stopped existing, which is the publish-last rule munmap already follows.

A worse consequence, checked and found not to exist

The obvious follow-on was that shrinking and then growing again would map fresh frames over still-present entries and leak the old ones. It does not: vibeos_vmspace_map_raw releases the previous frame when it overwrites a present, owned entry, and records the rmap change beside it. Checked before writing it down, which is the only reason it is not in this file as a finding.

mmap left pages behind when it ran out of memory

Two leaks, and only one was obvious.

The obvious one: void *page = hw_alloc_user_page(); if (!page || hw_map_page(...) != 0) return -ENOMEM; - when hw_map_page is what failed, page is non-null and nothing gave it back. That frame was gone for the life of the machine.

The one that was not: the pages already mapped stayed mapped, with no region describing them, because vibeos_vma_insert only runs once the whole loop has succeeded. They were recoverable, because mmap_cur is not advanced on the failure path so the next mmap reuses the same base and map_raw releases what it overwrites. "Recoverable by accident, on a path taken when memory has just run out" is not a design.

The review called both a leak; the second is narrower than that and is written down as what it is.

And the same two leaks in the function the review did not look at

hw_map_user_pages had them too, and it is what brk grows through. Grepping for the shape after fixing the reported site is what turned one fix into two.

What the impact is not

The leftover pages are mapped PTE_PRESENT with no PTE_USER, and hw_user_range_ok walks the page tables rather than the region list, so ring 3 cannot reach them. This is consumed memory, not a semantic hole - narrower than the review stated, and stated here as measured rather than as reported.

Correction, the same day: the fix covered one of two loops

The section above says mmap's leftover pages "are mapped PTE_PRESENT with no PTE_USER" and that ring 3 cannot reach them. That was true of one loop and false of the one that matters.

hw_sys_mmap has two allocation loops. The rollback committed here went into the first - the reservation branch, which maps guard pages without PTE_USER. The second is the ordinary anonymous path that every malloc reaches, it maps PTE_PRESENT | PTE_USER (plus PTE_WRITE when asked), and it still ended in a bare return -VIBEOS_ENOMEM with no rollback at all. On that path the leftover pages are reachable from ring 3, with no region describing them.

It was found while rewriting the same function for C5, not by any check. The review's M-002 had described the second loop's shape; the fix was matched against the first place that shape appeared and stopped there.

It matters more after C5 than before. The leftovers used to be recoverable by accident, because the mapping cursor was not advanced on the failure path and the next mmap mapped over them. C5 claims the range before anything is mapped, so nothing would ever map over them again - without a rollback they become a leak. Both loops unwind now, in the C5 change.

M-006: a length that wraps when rounded to a page (fixed 2026-09-11)

hw_sys_mmap rounded with pages = (len + 0xFFF) / 4096. For a length within a page of 2^64 the sum wraps and pages is zero: hw_mmap_claim saw no overflow in base + 0, claimed nothing, and the call returned that base as a success - with nothing mapped, and the same base handed to the next caller. Both branches, the ordinary one and PROT_NONE, had the arithmetic. It is refused before the sum now, with ENOMEM, which is what Linux answers when the aligned length is zero.

The same shape was one page further on in munmap and mprotect, found while checking every + 0xFFF in the file. Both refused addr + len < addr, but the aligned end adds 0xFFF more, so a range ending in the last page of the address space wrapped to end = 0. mprotect then changed nothing and reported success. munmap was worse: it handed the region list end - addr, which is 2^64 - addr, so every region above addr was removed while its pages stayed mapped - and the region list is what munmap consults to decide what to release. The bound is now len > ~0 - 0xFFF - addr, covering both sums. brk was checked and is safe: its address is already capped below the mmap arena.

The test is in the ring-3 ABI self-test (user/prog/hello.c), under the message the gate already asserts on. Red first. Because the four new checks share one message, the fix was then applied in halves: with only the mmap half the boot stayed red, which proves the munmap and mprotect checks fail by themselves and are not riding on the mmap ones. Both halves: linux abi ok. 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.

P6 step 5 — compaction

Reclaiming free memory and making it usable are different problems. vibeos_frame_alloc_contig is first fit, so after enough allocation and release a machine can have plenty free and no run of it long enough for a DMA descriptor or a staging buffer. Steps 1–3 recover free frames; only moving frames that are still in use recovers contiguous space.

largest_free_run was already measured and printed in meminfo, so the question "is there memory but not in one piece?" could be asked before this existed. Now it can be answered.

Why it could not have been done earlier

Moving a frame means finding every page-table entry that points at it. Until the reverse map, the only way to ask was to scan every address space and trust the result — the "reconstruct the truth from the hardware bits" mistake this rewrite exists to end. That is why the plan moved the reverse map from the last item of this phase to the first.

The window, and the restriction that closes it

Between copying a frame and repointing what maps it there is an interval in which a writer would store into the old copy and lose the write — silently, in a page somebody else reads later. Closing it properly means revoking write access first, taking the faults, and repointing: a stop-the-world, or a fault handler that understands migration. Neither exists here.

So a frame is refused unless no holder maps it writable. That is not a placeholder standing in for the real thing. It covers exactly the frames that fragment this machine in practice — page-cache pages and program text, which after a boot is most of memory — and it turns a race that has to be reasoned about into a condition that can be checked. Every refusal is counted by reason, so what compaction cannot move is a number rather than a silence.

Five refusals, and the order they are checked in is deliberate:

refusalwhy
pinneda page table or a DMA buffer must never move; the safety one, checked first
untrackeda reference that is not a mapping cannot be repointed — see below
writablethe copy-then-repoint window would lose a store
too many holdersa partly-moved frame is a state nothing here could recover from
racedthe holder list changed, so what was read is not what is there

The reference moves before the mapping does

The new frame gains an owner before the old one loses it, so the count never dips through a value that would let a release take the old frame while an entry still names it. Each entry is repointed with a compare-exchange against the value that was checked; a holder that changed in between is left alone, because it now points somewhere the mover does not own and forcing it would be the mover overwriting somebody else's decision.

Verified

Nine host-test groups, weighted so that two check it works and the rest check it refuses — the refusals are the specification here, not the edge cases. Six sabotage cases in scripts/dev/cases/mm-compact.txt, each confirmed red with the tree confirmed green again, and one more recorded there as unverifiable (see the last section).

One of those cases walked straight through a passing test, and that is the most useful thing to come out of this phase. The "only the first holder is checked" sabotage did not turn the tests red: the test mapped the writable holder last, the reverse map inserts at the head, so the writable holder was examined first anyway. The test asserted the right property about an arrangement that happened to hide the defect. It now maps in both orders, and the sabotage fails as it should.

The compactor, and the refusal the fragmentation test forced

vibeos_reclaim_compact(want) picks the window of want frames that needs the fewest moves and empties it into free frames outside itself. It returns the largest free run afterwards rather than a success flag: a caller compares that against what it asked for and decides. "A machine with plenty free and nothing in one piece" is precisely the state a boolean would hide.

Cheapest window rather than the first that could work, because the first suitable window is usually occupied by long-lived pages — the most work and the most likely to be refused.

The refusal that was missing

The fragmentation test failed on its first run, and for the right reason: the frames it moved never became free. An allocated frame that nothing has mapped still has an owner — whoever allocated it and kept the physical address. The reverse map cannot repoint that holder because it does not know it exists, so moving the contents leaves it reading the old frame while everything else reads the new one.

So a frame is now refused unless every reference is a mapping: owners == rmap_count. The harmless face of getting this wrong is a compaction that does not free anything. The harmful face is a page-cache page moved out from under the entry holding its address.

An earlier test asserted the opposite — that an unmapped frame should move, because it looked like the easiest case. It was wrong, and the test that disproved it was the one the plan calls the only honest one.

The tests were arranged differently from the kernel

Once the refusal existed, every move was refused: the tests held the allocation reference after mapping, so each frame had an owner that was not a mapping — namely the test itself. The kernel does not do that (decision D9: a caller that allocates in order to map hands the frame over and lets go), so the tests were wrong and the layer was right. They go through a helper now that follows the contract. Arranging a test differently from the code it exercises is how a test comes to disagree with reality.

The only honest test

Fragment memory to a comb — allocate until the allocator is empty, unmap every other page — and then ask for a contiguous run. The occupied frames are mapped read-only rather than merely allocated, and that is the point rather than a detail: what fragments a real machine and what compaction can actually move are both the mapped kind. Fragmenting with unmovable frames would be a test of the refusal, which is elsewhere.

A second case pins everything, so no window can ever be emptied, and asserts that the compactor comes back with a measurement that says so rather than a success. A caller told "done" that then failed to allocate would have no way to find out why.

One guard that cannot be shown to matter here

The compactor never takes a target frame from inside the window it is clearing. The reasoning is sound — a target inside the window is undone by the next move, and moves chasing each other around one window is a compactor that appears to work and never finishes — but removing the guard does not turn the tests red: the target scan starts at frame zero and finds a free frame outside the window first in every arrangement these tests can build.

It is recorded in the case file as unverifiable rather than listed among the cases that pass, for the same reason the AHCI cases are: "no case exists" and "the case exists and this environment cannot tell" are different things.

P4 step 3 — the staging buffer stops being a requirement

execve read every program into a 4 MiB staging buffer, and the interpreter into another 2 MiB one, so that a parser could look at the first hundred bytes and a handful of pages could be copied out. Six megabytes of kernel memory, and a whole-file read on every exec, to serve two things that need a few kilobytes between them.

This step removes the need for it. Both consumers now ask for bytes instead of indexing a buffer.

The page fill

vibeos_elf_fill_page_via takes a reader. A page of virtual address maps to at most one contiguous file range per segment, so it issues at most one read per segment that covers the page — not one per byte, and not one for the whole file.

The kernel's reader serves those bytes from the page cache, which is where they already are: X-P2 maps most image pages straight out of it, and the ones that have to be copied were the only reason the file was in the staging buffer at all. A range can straddle two cache pages, and the loop that handles it is not decoration — a segment's file offset has no reason to be page-aligned, and a version assuming one lookup per read would corrupt every page whose data crossed a boundary, which is most of them and only for some programs.

A read that fails leaves the rest of the page zeroed rather than half-written. A partially filled page of program text is indistinguishable from a correct one until it is executed.

The pointer form is kept as a reader over memory, so the host tests and the bootloader — which have the whole file to hand — do not have to invent a callback. Same code path, deliberately: two fills that could drift apart is how the copied pages and the mapped pages come to disagree about a program.

The parse

Everything the parser needs is in the first few kilobytes — the ELF header and the program headers — with exactly one exception: the interpreter path, which is the contents of a PT_INTERP segment at an arbitrary file offset. That single access is the entire reason image had to be the whole file.

vibeos_elf_parse_read takes a reader for it. Pass null and it behaves exactly as before, taking the path from the buffer and refusing a range that reaches past it.

One thing moved while doing this: the check that the path is terminated inside its own segment now happens after the copy rather than before it. With a reader the bytes are not addressable until they have been fetched, and checking a different copy from the one that gets used is how the two come to disagree.

What is not done, and what it is waiting for

The buffers are still there, and still 6 MiB. Nothing needs them now, but shrinking them is blocked on something outside this phase.

hw_read_file_cached returns one number that means two things: how long the file is, and how many bytes it put in the buffer. Today those are the same because the buffer holds the whole file. Shrink the buffer and they diverge — and the parse needs the file length for its bounds checks while the buffer only holds the headers. Passing the staged count would make the parser refuse every segment past the window, which is every segment.

Separating them is a change to the I/O path, and that path's truncation semantics are the subject of one of this project's most expensive lessons: a FAT read that reported a short read as a complete file, so execve parsed whatever the previous program had left in the shared buffer. g_fat_chain_error and a byte count that reflects what was actually copied exist because of it.

So this belongs to the disk-and-I/O refactor rather than to the memory manager, and doing it here would mean changing that function's contract in passing — which is how that defect happened the first time.

What this phase leaves behind is that the dependency is gone: the staging buffer is an optimisation now, not a requirement. When the I/O layer can say "the file is N bytes, I gave you M of them", both buffers can shrink to a header window and the refusal for a file whose headers exceed it is one line.


Update, after I3 of docs/io/

The I/O layer can say it now: the read reports the file's length and stages only what fits, vibeos_elf_header_extent says how much of a file the parser needs, and hw_proc_create takes both numbers with the signature saying which is which. The refusal for a file whose headers exceed the window is one line, as predicted.

The buffers still do not shrink, and the reason changed. It is no longer a design problem; it is a defect with a reproduction.

Setting both windows to 64 KiB works and runs every program on the media, and turns the gate red with mm_poison_hits=3019 deterministically. Shrinking either window alone is clean; only both together fail; and wasting the six megabytes the two shrinks free makes the poison disappear while the windows stay small. So the shrink changes only which frames the allocator hands out, and something writes to a frame it has already released.

That is the use-after-free this subsystem has been chasing at roughly one boot in sixteen — now reproducible on demand, 3019 times a boot. Written up in io_read_contract.md, with the recipe recorded in arch_hw.c beside the constant somebody would change.


Closed

The windows are 64 KiB each and P4 step 3 is done. What held it up was not a use-after-free: vibeos_frame_init never initialised the descriptor flags, so the poison detector judged virgin frames and reported 3019 corruptions in a boot with none. The staging sizes only decided where that descriptor table landed in physical memory, which is why the symptom tracked them so convincingly.

Written up in mm_frame_init_flags.md, with the measurement that settled it and the two tools that were wrong on the way.

The use-after-free that was never there

Status: closed. vibeos_frame_init now initialises the descriptors it is handed, a host test represents the defect, and the two exec staging windows have shrunk from six megabytes to a hundred and twenty-eight kilobytes — which is the memory manager's P4 step 3, held up by this for a phase.

What it looked like

After I3 of the I/O plan separated a file's length from its staged byte count, nothing needed the four-megabyte exec staging buffer any more. Shrinking both windows to 64 KiB ran every program on the media and turned the boot gate red:

reason=invariant_failed:mm_poison_hits=3019

Every boot. The same number. This project's long-standing intermittent corruption — a musl program tripping over its own malloc bins, roughly one boot in sixteen — appeared to have become reproducible on demand.

Three facts made it look like an allocator-layout bug, and they were all true:

  • shrinking either window alone was clean;
  • only both together failed;
  • deliberately wasting the six megabytes the two shrinks freed made the poison disappear while the windows stayed small.

The only thing the shrink changes is which physical memory gets used for what. So the conclusion drawn — and written into the source beside the constant — was that something writes to a frame it has already released, and the buffers must stay large until that was closed.

What it was

The frame layer's poison detector reports how often. It could not say which frame, what was there, or who released it, so every reading of this was an argument about mechanism with no evidence about mechanism. Adding vibeos_frame_set_poison_watch — one line per event, capped at eight — answered it on the first boot that got far enough:

[MM] POISON_BROKEN frame=0x1e99000 word=0x0 found=0x0 freed_by=0x0 owners=0x0
[MM] POISON_BROKEN frame=0x1e9a000 word=0x0 found=0x0 freed_by=0x0 owners=0x0
...

Three things in that line settle it:

  • freed_by=0. The release writes a tag into word 1 of every frame it poisons. Zero means no release ever ran. A frame nobody released cannot be a use-after-free.
  • found=0. Not garbage, not a live structure — nothing.
  • frame=0x1e99000, and the frames after it, consecutive. The boot's own banner says base=0x1780000 reserved_prefix=0x719000; their sum is 0x1e99000 exactly. These are the first frames the pool ever hands out.

So virgin frames were being judged against a poison that was never written. frame_check_poison guards on VIBEOS_FRAME_WAS_FREED precisely to avoid that. The guard was right; the flag was lying.

vibeos_frame_init set every field of a descriptor except flags, and frame_push_free preserves that bit on purpose — "the one fact about a frame that must outlive its contents". On a table full of the bump allocator's leftovers, flags &= WAS_FREED preserves garbage: every frame whose stale byte happened to have bit 0x10 set came up claiming a release that never happened.

And the staging windows decide where in physical memory that descriptor table lands. That is the whole of "either alone is clean, both together fail", and the whole of why wasting the freed six megabytes made it go away.

Why the existing test could not see it

tests/kernel/frame_tests.c already had the right case — "a frame that has never been freed is not judged by the poison" — and it passed, because its setup() starts with memset(g_table, 0, ...). The kernel does not. The property was correct and the arrangement made the defect impossible to observe: CLAUDE.md's "a test can be right about the outcome and wrong about the mechanism", for the fourth time.

The new case fills the table with 0xFF instead — 0xFF rather than a chosen value, so the test does not encode which bit the layer happens to use — and asserts three things: no poison hits, no double allocations (a stale owner count is a frame handed to a second holder), and a correct free count. Removing the one line of the fix turns it red; putting it back turns it green.

What is kept

  • vibeos_frame_set_poison_watch and its printer. The counter says how often; this says what. It is the reason this closed after three failed readings.
  • vibeos_frame_dirty_at_init() — how many descriptors arrived already marked. Reported rather than silently cleared, because "the table was clean" and "the table was dirty and we coped" are different states.
  • The windows at 64 KiB each. Eight boots: 7/8, the one failure being the pre-existing busybox_cat wedge, which is the same background rate as every other measurement here.

Two things the tools got wrong on the way

The first report deadlocked the machine it was explaining. The watch runs inside the frame layer's lock and called vibeos_frame_owners, which takes it again. The boot stopped mid-line, at owners=0x. vibeos_frame_owners_locked is the one a watch may call, and it is now declared in the header saying so.

gcc accepted that call undeclared and clang did not — the warning count moved from 0 to 1, which CLAUDE.md says to read the way rc= is read. It was read.

The banners lied about the sizes. [HW] exec staging buffer: 4 MiB was a string literal, printed while the constant said 64 KiB, and for a moment the first run looked like the built image had not booted. They print the constant now.

And a check had been sitting red, unread. check-mm-layering.sh allows three calls to the bootstrap allocator; the reverse map's node pool was moved before vibeos_frame_init a phase ago and made it four, and the limit was never raised with it. It is four now, with a note that a fifth should be a decision rather than a discovery.

P7's latency property: the bounds were there, the counters were not

Status: closed. Every wait a syscall can reach has a bound, each bound now increments a counter, the counters are reported in one bracketed line, and the boot gate asserts they are zero — and a missing line is a failure rather than a pass.

What was already right

The bounds all existed and each had been argued for in its own place:

waitboundreachable from
virtio-blk completion poll100,000,000any read or write
AHCI port-busy check1,000,000any read or write
AHCI completion poll20,000,000any read or write
virtio-net transmit2,000,000a socket write
TLB shootdown acknowledgementbounded, tlb_timeoutsfork, COW fault

The shootdown was the one done properly, and it is the model: bounded, counted, and asserted by the gate — because "a shootdown that silently never fires leaves the bug exactly as it was, with every boot green".

What was wrong

VIBEOS_BLK_TIMEOUT was produced by nobody. It is defined in blkdev.h, named by blk_result_name, printed by kmain on the [IO] MUSTBEZERO line, and asserted by the boot gate. And no driver had ever returned it: both disk drivers hit their bound and returned -1, which is the same value the device's own error bit produces. "The disk took too long" and "the disk said no" arrived at the block layer as one thing.

So the gate carried an assertion that was green and could not go red. Same family as this project's "a line in the serial log is not a check": the mechanism was in place, the reporting was in place, and the two were not connected.

g_tx_timeouts was incremented and read by nobody. virtio-net's transmit counted its timeouts into a static that no accessor exposed and no line printed. That is the defect this project produces most often — a number written and never read — and the rule for it is already written down: when adding one, write the assertion that would fail if the caller disappeared.

What was done

Each driver counts its own bound firing and exposes the count. vibeos_x86_64_blk_bind takes that accessor, and the adapter samples it around the driver call: if the count moved, the request gets VIBEOS_BLK_TIMEOUT rather than a generic failure.

Sampled around the call rather than returned through it, deliberately. The driver entry points are int-returning and threading a reason through three of them in two drivers is a wider change than the fact deserves. The counter only grows, so a move across the call means a bound fired. Under concurrency another core's timeout could be attributed to this request — accepted, and worth stating: the counter the gate asserts is the driver's own and is exact, and mislabelling which request timed out matters far less than the alternative, which was not knowing anything had timed out at all.

kmain prints one bracketed line — [IO] WAITS blk_timeouts=… net_tx_timeouts=… — because a line assembled from several serial_puts calls is several critical sections, and this project has had a gate report failures that were a marker cut in half.

Confirmed by breaking it

Set virtio-blk's bound to 1. [IO] MUSTBEZERO … timeout=0x1 and [IO] WAITS blk_timeouts=0x1. That is the whole point of the change: before it, the same sabotage produced a boot that failed with a generic error and a timeout count still reading zero. The boot fails for other reasons too — a disk that times out on every read cannot boot — so the case is about the failure being named as a timeout, not about which verdict comes out.

Remove the WAITS line. invariant_failed:io_wait_counters_missing. A counter that stops being printed must not read as a counter that stayed at zero; that distinction is why the check treats a missing line as a failure.

The store that never landed

Continues mm_ring3_write_nul.md. That entry established the signature; this one removes five explanations and confirms the process migrates between cores. Every step is a measurement. The mechanism is not settled, and the explanation that fitted best is refuted below by one grep.

The signature, restated

Three to five boots in ten, a user buffer whose first sixteen bytes read as NUL while the rest is correct:

[HW][SYS] write(ring3): ^@×16=120

say("STRESS_OK rounds=", ...) builds that in char line[128] on the user stack, and STRESS_OK rounds is exactly sixteen characters.

What each measurement removed

QuestionAnswerInstrument
Is the page moving while the kernel reads it?Nothe same eight bytes read twice in write: first8 == again
Does the buffer cross a page boundary?Nopage offset 0xc60, 21 bytes — one page
Does the copy-on-write copy lose data on this page?Noa fold of the whole page before and after the resolution: cow_copy_changed=0, on every boot including corrupted ones — sensitivity measured below, and it is not what it looks like
Does the page fault only once?Nothe fault ring shows svc-stress itself faulting on that page four times
Are frames handed out twice?Nodouble_allocs=0, backbuf_shared=0, backbuf_lost=0
Does it need more than one core?Yeseight boots at -smp 1: zero corrupted writes, STRESS_OK every time

The single-core result is the one that closes it. Four cores: three to five failures in ten. One core: zero in eight.

What that leaves

The copy is faithful and the data is not in it, so the store never reached the frame the copy read. The defect needs a second core. The process is single-threaded and forks about a hundred times before printing.

The migration is measured

The core identity is recorded on both sides now, and it says outright that the process moves:

write on cpu=1   faults resolved on cpu=0, cpu=0, cpu=1, cpu=0
write on cpu=2   faults resolved on cpu=1, cpu=2, cpu=2, cpu=2, cpu=1

svc-stress migrates, and its stack page is resolved on cores other than the one that later writes to it.

The explanation that fitted, and why it is wrong

CLAUDE.md describes a hole that fits this shape exactly:

hw_share_user_leaf revokes write permission to mark a page copy-on-write and then invalidated the calling core's TLB - which is complete only if no other core is running that address space.

with the optimisation built on it: "A single-threaded fork now sends nothing at all, which is nearly all of them." "Single-threaded" is true at an instant and says nothing about which cores have held a translation over time - so a core that ran the process before the fork could keep a writable entry for a page the fork has since made read-only.

That explanation has a false premise and is discarded. There is no PTE_GLOBAL and no CR4.PGE anywhere in this tree, so every CR3 write flushes the whole TLB. A core the process migrates onto reloads CR3 to get there. A stale entry cannot survive the trip.

It is written down rather than deleted because it fits every symptom, it is the first thing the next reader will think of, and it takes one grep to refute.

So the mechanism is open

What is established: the store is lost before the syscall, the copy-on-write copy is faithful, the page faults several times in the same process, the defect needs more than one core, and the process migrates between them.

What is not: how a store executed on one core fails to be visible to the same process on another, given that CR3 reloads flush. The remaining candidates - and this is a list to test, not a conclusion:

  • two cores inside vibeos_vmspace_fault on one frame. The sole-owner fast path grants write on the existing frame while the copy path reads that same frame into a fresh one. The undo window is guarded by a re-read of the owner count, and its comment records a real defect fixed there before; whether the guard covers a parent and a child faulting simultaneously is not something reading settles.
  • the copy racing a writer. copy_frame(fresh, phys) reads phys with no lock. A core holding write permission on phys at that moment produces a copy that is faithful to some state and not to the one the fold compares - the fold reads the page through the mapping after the resolution, so it cannot see a source that changed during the copy.
  • the fold itself — settled, and the answer is uncomfortable. See below.

What the fold can actually see

copy_frame was sabotaged - d[0] = 0, one quadword the copy does not carry, the same shape as the real defect - and the fold does fire: 40 COW_COPY_CHANGED lines in one boot. So the detector is not blind, which is the first thing that had to be established.

The ratio is the uncomfortable part:

cow_resolved      = 362      every resolution
copied            = 182      those that actually copied a frame
cow_copy_changed  =  40      caught, with every single copy corrupted

Two things make it miss:

  • 180 of 362 resolutions never copy. The sole-owner fast path grants write on the same frame, so the fold compares a frame with itself. Zero there is an arithmetic identity, not a result.
  • Of the 182 that do copy, 22% were caught. Zeroing the first quadword only moves the fold when that quadword was not already zero, and most freshly faulted pages are largely empty.

So cow_copy_changed=0 on a clean build is much weaker aggregate evidence than it appears, and the earlier statement that "the copy is faithful" was claimed at a strength the instrument does not support.

The inference about this defect survives, for a different reason than sensitivity. The bytes lost on the page in question are STRESS_OK rounds - definitively non-zero text. Had the resolution dropped them, fold_before would have carried them and fold_after would not, and the fold would have fired on that page whatever its average sensitivity is. It did not. So the resolution did not lose them there. That is a claim about one page and one fault, and it is all the fold can support.

And a coverage hole worth more than the measurement

The boot passed green with copy_frame sabotaged. Every copy-on-write copy in the system losing its first eight bytes, and the gate reported cli_and_network_verified. Forty corrupted pages went by without a single assertion noticing.

That is a bigger finding than anything above: the machine has no check that copy-on-write preserves what it copies. cow_copy_changed is now that check, and it is the reason to keep it rather than treat it as investigation scaffolding.

Two things this corrected in the tree

A comment that was wrong. Beside the copy-on-write trace:

What it answered once already: the page that loses a wide store faults exactly once, so the store is lost after a successful resolution rather than to a second fault nobody handled.

The fault ring shows that page faulting four times in the same process. The conclusion drawn from "exactly once" was the premise of the previous investigation.

A fault nobody had separated. One boot's ring carries err=0x3 — present and write with no user bit — at a kernel rip. That is the kernel writing into user memory on the process's behalf with CR0.WP set, on the same page, and it is a different event from the ring-3 stores around it.

Instruments added, and why each is shaped as it is

  • The double read in write. Two reads of the same eight bytes separate "the page is moving under the kernel" from "the data was already gone". It fires only on leading NULs followed by real text, because a detector that flags any NUL anywhere would catch every program writing a binary byte, and this project has a rule about detectors that report healthy behaviour.
  • The fault ring, sixteen entries, filtered to the corrupted page and printed in the same critical section as the write report. The existing trace floods - its own comment says it changes the timing of the defect it looks for - so the faults are recorded always and printed only by whoever finds a corruption. That is the rule about taking the state at the crash rather than going back for it.
  • The page fold, before and after resolution. A fold rather than a byte sample, because guessing which offset matters is how a detector ends up measuring its own assumption - which happened earlier the same day, when a frame-ownership probe asked for owners != 0 and reported 1000 frames of 1000 because frame_take sets one owner when it hands a frame out.
  • VIBEOS_SMOKE_CPUS on the boot gate. Used as an experiment, not a configuration: a one-core boot fails other assertions honestly and still prints the counters. A defect that needs two cores in one window and one that does not are different defects, and taking the second core away is the cheapest way to tell them apart.

What is next

Settle whether the fold can fail before trusting that it did not, then work down the candidate list above.

The fix that was written here as "leading" - shoot down every core that has ever run the address space - has been removed, because it repairs the explanation that has just been refuted. A fix aimed at a mechanism nobody has demonstrated is how this project ends up with a mechanism configured and consulted by nobody.

ring3_write_nul and cow_copy_changed are both reported and both belong in the boot gate's must-be-zero set once there is a fix, so that a regression is a number rather than a story. They are not asserted yet: ring3_write_nul is non-zero on three to five boots in ten today, and a check that starts red is a check nobody reads.

SSE was enabled and never saved (fixed)

The root cause of the lost store, of the argv vector that reads as white pixels, and very likely of a good part of this project's "one boot in N" family.

The finding, in four numbers

fxsave / fxrstor / xsave, whole tree :     0
XMM instructions in the kernel image :  6955
XMM instructions in svc-stress       :   782
CR4.OSFXSR | CR4.OSXMMEXCPT          :   set, in ap_boot.S

SSE is turned on. Both the kernel and user programs use it heavily. Nothing anywhere saves or restores XMM or FPU state.

So a user task's XMM registers survive a syscall, an interrupt, or a context switch only by luck. Not a race with a fine window — the kernel executes vectorised code on the way in and hands the registers back with whatever it left there.

How that produces exactly sixteen NUL bytes

svc_stress builds its output in char line[128] on the stack. Its disassembly, at 0x800000021e:

pxor   %xmm0,%xmm0
movdqa %xmm0,0x20(%rsp)      ; sixteen bytes of zeros, in one store

clang uses a 16-byte XMM store for stack buffers. put_str copying "STRESS_OK rounds=" does the same thing in the other direction: load sixteen bytes into an XMM register, store them.

Preempt between the load and the store, let anything else run, and the store writes what that something left behind. Sixteen bytes, always exactly sixteen — one XMM register — which is the signature that had no explanation for the whole investigation.

And the value is zero because svc-stress forks about a hundred children running the same binary, and that binary opens with pxor %xmm0,%xmm0. The clobbering value is overwhelmingly likely to be zero, which is why the corruption looked like a freshly-zeroed page and sent ten hypotheses into the memory manager.

And why the argv vector reads as white pixels

gui.c:

#define COL_TITLETXT 0x00FFFFFFu
#define COL_CURSOR   0x00FFFFFFu

The desktop fills rectangles with that colour, from the kernel, on timer and input interrupts — interrupts that land on whatever user task is running. A vectorised pixel fill holds 0x00ffffff00ffffff in an XMM register: two white pixels in one 64-bit lane.

The task resumes with that register still loaded and stores it into its own buffer. mm-argv-poison.txt records the value as 0x00ffffff00ffffff, and the same value turning up independently in the block layer as an out-of-range sector.

It was read as "the desktop is painting over memory that belongs to something else". It is the exact opposite: the desktop is not touching that memory at all. It left a value in a register, and the process wrote it there itself.

That is why every memory detector read zero and was right, and why the code comment above the back-buffer allocation - which describes a real, different, already-fixed defect with the same symptom - sent the investigation into the allocator four separate times.

The ten hypotheses this closes

Every one was killed by measurement before the cause was found, and every one was in the memory manager because the symptom looked like memory:

Killed by
back buffer shadows the low user windowSHADOWS_USER=0; pmm_reserve truncates the region
framebuffer inside managed RAMFB_IN_RAM=0
desktop overruns its buffercanary page, intact on failing boots
frames handed out twicedouble_allocs=0, backbuf_shared=0, backbuf_lost=0
the kernel reads the page wronglyfirst8 == again
the buffer crosses a pageoffset 0xc60, 21 bytes
the copy-on-write copy loses datathe page fold, validated by sabotage
a stale TLB entry survives migrationno PTE_GLOBAL, no CR4.PGE — CR3 reloads flush
compaction moves the framereclaim never runs: scans=0
a frame is freed while still mappedevery free checked, six boots, zero

The last one mattered: that check samples one free in sixteen by default, so its zero had been weak evidence for months. It is a build knob now (HW_FREE_CHECK_MASK) rather than a literal somebody edits and puts back, which is what had happened twice.

None of these was wrong to test. What is worth noticing is that the shape of the evidence — a page reading zero where a process wrote — pointed at memory so strongly that ten separate mechanisms were checked before anybody asked what else is exactly sixteen bytes wide.

Done, and it took two halves

One: the context switch saves and restores. A 512-byte 16-byte-aligned area per task, fxsave beside the line that already saved the integer context and fxrstor beside the one that loaded it. Eager, not lazy: CR0.TS with a trap on first use needs a fault handler, a per-core notion of who owns the registers, and cross-core invalidation on migration — three mechanisms on the path this kernel has the most defects in, against about a hundred cycles on a switch that already costs far more.

The initial state is built by hand rather than zeroed, because MXCSR lives at offset 24 and zero there unmasks every SSE exception: the first floating-point operation in a fresh program would fault. fork copies the parent's area — "resume exactly where the parent is" was only ever true of the integer registers — and a new thread gets a clean one. The exit path restores without saving, since the dying task's registers go with it but its successor must not inherit them.

CR4.OSFXSR is now set on the boot processor too. Only ap_boot.S ever set it, so the BSP had it because UEFI leaves it on — a coincidence that had held, and fxsave without it is #UD.

Two: the kernel stops using vector registers at all, which is what Linux does and for the same reason. A syscall that returns to the same process never reaches the scheduler, so nothing saves anything, and every XMM instruction the kernel runs on that path destroys the caller's state regardless.

That half was found by measurement, not review. With only the context switch fixed, the corruption went from three boots in ten to one in twelve — a large improvement that is not a fix, and the number that would have been reported as one had the sweep been five boots. The deterministic XMM test was already green at that point, because it uses getpid, which is too trivial to touch XMM. Two criteria, one green and one not, is what stopped the work at the halfway mark being called finished.

The portable half needed it too, and that took a second round: -mno-sse on the arch target alone left kernel/core, kernel/mm, kernel/io and kernel/fs compiling with SSE, and they run in the kernel. They are a second library now rather than a flag on the shared one, because the shared one is linked into the host tests where the ABI passes floating point in XMM.

The near-miss worth recording: the first attempt was one library with if(IMAGE AND NOT TESTS) around the flags. Both are on in every real build, so it would have compiled to nothing while looking like a fix, under a comment explaining why it was necessary. That is this project's most repeated defect — configured and consulted by nobody — and it was caught by asking when the condition is true rather than by re-reading the code inside it.

Verified

beforeafter
abi: xmm lost across a context switch3 boots of 30 of 22
corrupted ring-3 writes3–5 boots in 100 of 22
host tests, clang build, boot gategreengreen

One boot in the sweep panicked in THREADS.ELF with three cores on one address space. The same failure, same phase, same signature appeared earlier the same day on the unmodified kernel, so it is pre-existing and separate — recorded rather than folded in.

What to gate

ring3_write_nul and cow_copy_changed both belong in the boot gate's must-be-zero set once the fix lands. Neither is asserted yet, because a check that starts red is a check nobody reads.

The stronger gate is the one this finding suggests: a user program that fills XMM with a known pattern, does syscalls and lets itself be preempted, and checks the registers still hold it. That is a test the machine can fail deterministically instead of three times in ten.

P6 steps 2-4 — watermarks, admission and pinning

What the layer decides, and what it deliberately does not know

kernel/mm/reclaim.c is small on purpose. Everything it decides is a policy question and every fact it needs belongs to somebody else: how much is free is the frame layer's, which pages are droppable is the cache's. The one thing kept here is which frames are pinned — because "pinned" is not a fact about a frame's contents but a statement about who else holds an address for it, and nothing else is in a position to know.

Two questions, not one

When to reclaim. Below the low mark a reclaim runs; below the minimum an unprivileged allocation is refused. The reserve is the point: privileged allocations — a page table the teardown itself needs, the kernel's own bookkeeping — go through at any level. A minimum that refuses everybody is a machine that deadlocks at exactly the moment it needed to free something.

Marks are a fraction of what the machine actually has (a 64th and a 256th) rather than constants, because a number that suits a large machine starves a small one and this kernel runs on both.

What to reclaim. Cheapest first, never anything pinned. A clean page-cache entry costs nothing to drop because the file still has it. Anything else costs a write, and there is nowhere to write yet — so this phase evicts the clean tier and says so: skipped_no_swap counts what it was not allowed to take, so "reclaim did nothing" and "reclaim had nothing it could take" are different numbers rather than the same silence. A gate that could not tell them apart would be satisfied by a reclaim that had quietly stopped working.

The clean tier is the page cache's own eviction, exposed rather than reimplemented. It already owns the clock hand; a second copy of that knowledge here is how two structures come to disagree about the same fact.

Pinning is a safety property, not a tuning one

An eviction that reaches a page table, a DMA buffer, or a frame a device holds the address of does not make the machine slow. It corrupts it, asynchronously, which is the hardest kind of defect this project has. Page tables are pinned where they are allocated and unpinned before release — the second half matters as much: a pin that followed a frame onto the free list would give the next tenant a frame that can never be reclaimed, which is a leak no counter would report because the frame is perfectly accounted for.

Verified

Ten host-test groups, ordered so the two properties that corrupt when wrong — pinning, and the reserve admitting privileged work — come first. Five sabotage cases, each confirmed red and the tree confirmed green again:

  • the reserve refuses privileged allocations too (a deadlock)
  • the minimum is off by one
  • a pin does not take
  • the shortfall is not counted
  • a transition is counted on every query rather than once

The watermarks were configured and read by nobody

The first version of this set the marks, wired the clean tier, and then never consulted either from the allocation path. They were a number nothing read — this project's most repeated defect wearing new clothes, and the same one S-P6 had just found in the scheduler's quantum.

The pressure service found it in one boot. hw_alloc_page is split now: an unqualified door for the kernel's own work, and hw_alloc_user_page for pages a process asked for, which is what the reserve is held back from. Below the low mark the allocator tries the clean tier before refusing anybody.

What the pressure service found next, and why it is not in the boot

user/prog/svc_press.c allocates 256 KiB at a time and touches every page. At 80 blocks — twenty megabytes, on a guest with four hundred — the machine stops answering and the serial log fills with binary.

That is not exhaustion and it is not the watermarks refusing anything: twenty megabytes is five per cent of memory. It is a defect this boot has had all along, which nothing had ever asked it for enough pages in a row to find. The region pool is 2048 entries against the 5120 pages that run wants, and vibeos_vma_insert refuses cleanly and counts the refusal, so the pool running out is handled — but the log is too corrupted by then to read the counter, which is itself a fact worth recording.

The service is built and shipped and not started by init. Starting it would turn every boot red on a defect that is not reclaim's, and this project has a rule about gates people learn to ignore. Run it by hand from the shell.

Two things to do next, in this order: find what corrupts the console at that point — it is the only lead that explains binary in the log rather than a refusal — and then decide whether the region pool should grow or whether mmap should record a range as one region instead of one per page.

2026-09-24: the defect is still there, and what it is not

Picked up because nothing on a machine has ever reclaimed an anonymous page (mm_swapout_protocol.md) and svc-press is the obvious load to change that. It cannot yet: started from init, it still stops the machine - now at 112 blocks, 28 MiB, not 80, and with a clean log rather than binary (the console has been fixed several times since). 28 MiB is far above the low watermark, so reclaim is not running when it happens; this is not a reclaim defect.

What the evidence says, from QEMU's exception log (VIBEOS_QEMU_TRACE=<file>, added to the gate for this - every exception with the full CPU state, which a machine whose console lock is held by a dead core cannot print):

  • The first bad event is always a ring-0 fault at a garbage address - 0x0, 0x2, 0x3001, the padding between two functions, or a stack address - on a core that was inside the timer interrupt, on the timer's IST.
  • The clearest one: a pop %rbp; ret on the timer's IST read this tick's own interrupt-frame values - the saved RFLAGS (0x202) as the frame pointer and the interrupted RSP as the return address - and executed seven bytes of the interrupted stack before faulting. Another core resumed a context whose rip was a user stack address with a kernel code selector.

Ruled out by guards that were added and did not fire (the probes were kept as scripts/dev/patches/press-hunt-instrumentation.patch until 2026-09-29, when it was deleted: it applied to an arch_hw.c that no longer exists, and git history keeps it):

  1. the timer re-entered on the same core (a per-core depth counter);
  2. the timer path running off the bottom of its 16 KiB IST slice (a guard band checked on entry and exit of every tick);
  3. interrupts left enabled anywhere on the timer path (RFLAGS.IF checked after each of its seven steps);
  4. a task interrupted while running on the timer's IST (checked when the context is saved);
  5. a kernel-mode context whose rip is not kernel code, saved or loaded through the scheduler (__kernel_text_start/_end).

None fired, and nothing printed. That leaves the IST slice being written by something other than its own core's timer path, or state the timer path reads being corrupted before it runs - the obvious candidate for both is a frame that svc-press is handed while the kernel still uses it. double_allocs and the free-page poison would say so, but the machine stops before either is printed; the next step is to make those report at the moment they fire without taking the console lock (the poison watch now records under the frame lock and prints later, which is half of that).

Found: the kernel was in its own frame pool

The next step was taken - scripts/dev/wedge_report.py now reads g_mm_stats and the poison records out of the stopped guest through QEMU's monitor, which needs no console - and it said nothing was wrong: no poison hit, no double allocation, 97,116 frames free. What it did give was frames_allocated=8552.

With the frame pool's base (0x1780000) and its reserved prefix (1,817 frames), 8,552 allocations reach frame index 10,369. The kernel is linked at 0x4000000, which is index 10,368. The machine stopped on the allocation that reached the kernel's first page, and a zeroed kernel page is the IDT and the timer's IST - which is every symptom above, and why no guard in the kernel's C could fire: the CPU faulted on the way into the handler.

The loader took the firmware memory map in phase 2 and handed that copy over. The kernel's segments, boot_info, the map array and INIT.ELF are all allocated after it, as loader memory the map would have called reserved - but the copy called all of it free, and the largest free region, which becomes the frame pool, contained the kernel. Only the key was refreshed before ExitBootServices, never the regions. Nothing allocated 40 MiB before svc-press, so no boot ever reached it.

Fixed in two places, deliberately:

  • The loader refreshes the map as its last step before handoff (vibeos_bootloader_refresh_memory_map, host-tested; the array is allocated with slack for the longer map). Failing to refresh is fatal - handing over a map that calls the kernel free is worse than not booting.
  • The kernel reserves its own image (__kernel_image_start/_end from kernel.ld) regardless, and prints [HW] kernel image ... in_free_map=. The gate asserts 0: the reservation is a second line, and a map that needs it is a wrong map.

Shown both ways with svc-press started from init: both halves removed stops the machine at 112 blocks, the original signature; the reservation alone, with the refresh removed, runs it to 256 blocks and PRESS_OK while the gate goes red on kernel_image_in_free_map. The earlier episode at 80 blocks with binary in the log is very probably the same defect with a different layout - not reproduced, so not claimed.

What svc-press found next (M-061)

With the machine surviving, svc-press unmaps its 64 MiB in a loop, and the gate goes red on userland_frames_lost=2486. tlbq_overflow=2478 in the same log: a burst of munmap fills the TLB quarantine's 512 slots faster than the other cores flush, and an overflowing frame is leaked on purpose (H-015 - leaking is safe, recycling is not). That was the known follow-up of H-015, unreachable until now. Fixed the next day by parking only frames another core could still reach (mm_tlb_quarantine.md, "M-061"); svc-press now runs in every boot and the gate reads its verdict. Note also that 256 blocks were all granted: 64 MiB of a ~420 MiB pool never reaches the watermarks, so a load that forces reclaim has to be larger, or the machine smaller.

2026-09-25: the first load that forces reclaim, and what it found

svc-reclaim asks the kernel how much memory is free (sysinfo, added for it), fills memory down to the low watermark and past it - enough to use up the clean page cache and push the anonymous tier into swap - then reads every page back and checks it, releasing each block as soon as it has been checked. It stops past the mark at 12 MiB or when three quarters of swap is in use, whichever comes first. It runs in the boot's self-test, between the signal test and the BusyBox commands, because those run one at a time: started from init beside the thread tests it had their allocations refused one boot in two, and run last it emptied the page cache the exec audit reads. The first version went the full 12 MiB, which is more than the clean cache and 8 MiB of swap can give back: a sabotage run that happened to leave svc-press out took it to the minimum, every other program was refused, and the machine starved with swap full. A load that forces reclaim has to stay inside what reclaim can return. It runs in every boot now, and a boot shows the thing this file said never happened: about two thousand anonymous pages written to swap and brought back, each compared with a hash of what was written, with no slot left allocated at the end.

Getting there took five defects, none of them in reclaim itself:

  • A shootdown deadlock (M-065). A swap-out holding a claim waited for a core it believed was running the victim's address space; that core was tearing the space down from the kernel's tables, waiting for the claim with interrupts off. The shootdown now targets what is in a core's CR3, waits per core, and a waiting core answers flush requests itself - and a machine-wide pending count that concurrent shootdowns wiped is gone.
  • The gate's disk could not hold swap (M-064). Processes died of each other's data. A per-slot hash said the slots did not give back what they were given; a raw write-then-read at one LBA, below the swap layer, said the same; eight slots written at boot on one core said it deterministically - every slot aliased slot 0 or 1. The disk was QEMU's vvfat, which is a host directory and not a medium. The gate boots a real FAT image now, and the kernel refuses an area whose slots alias.
  • Page-in leaked a frame per page (M-063) - two owners, one mapping. The frame accounting said 764 lost against 756 pages brought back.
  • A swapped-out entry was invisible to fork, munmap, teardown, mprotect and the syscall range check, and came back with the wrong permissions (M-063). Found by reading, once the leak had pointed at the page-in path; each is a host test now, and sabotaging each turns it red.
  • virtio-net's interrupt line was live and nobody's (M-066).

Still open: two early boots on the real image wedged in the AHCI log disk's interrupt path (M-067), not reproduced since.

A detector that could not tell a new tenant (M-062)

Sabotaging svc-press to exit without unmapping its 64 MiB raised mm_free_while_mapped. With every release checked it reproduced one boot in three: four frames, all in svc-stress, all reported with owners=0 and owners_now=1 or 2 a moment later. The watch runs after the frame lock is dropped, and in that time svc-stress had taken each frame, mapped it, unmapped it and freed it again - two samples at two instants, CLAUDE.md's rule about the rmap audit, in another detector. The frame layer now counts hand-outs per frame and the watch discounts a frame taken since its release: the same reproduction gives 7 free_watch_torn and no reports, while a real one - fork giving its reference back - is still reported, with owners_now=0.

Found and fixed on the way, both real and neither this defect:

  • The page cache handed out bare addresses. vibeos_cache_get returned a frame with the cache's lock already released, and the loader mapped it and the exec reader copied from it; an eviction in between frees the frame, and the mapping then takes its reference on somebody else's page. vibeos_cache_get_ref takes the reference under the lock.
  • The clean tier never reported a frame freed. It told an eviction from an empty slot by looking at the entry, and the eviction empties the entry it frees - so a page just given back looked like a free slot, and the loop stopped at its first success having counted nothing. freed_clean=0 in every log this project has is that, not an idle tier.
  • The poison watch printed under the frame lock, taking the console lock inside it - the trap CLAUDE.md records for a watch that called a locking accessor. It records now and the allocating core prints outside the lock.

The failure that had not happened, and the one underneath it

Going after the argv defect produced a different one, characterised far better, and showed that the boot gate had been naming the wrong subsystem for it.

What was being looked for

scripts/dev/cases/mm-argv-poison.txt: a process's argv reads 0x00ffffff00ffffff — two white pixels, COL_TITLETXT from the desktop — with every memory counter at zero. The case file's stated next question was whether hw_alloc_pages_contig reserves every frame in the run it returns.

Four hypotheses, each killed by a number

Nothing here was settled by reading. Each measurement was built, run, and in three cases refuted the thing that motivated it.

HypothesisInstrumentResult
The desktop's buffer shadows the low user window a Linux program is mapped intoprint the buffer range against VIBEOS_HW_LOW_USER_*SHADOWS_USER=0. vibeos_pmm_reserve truncates the region, so pmm_base is 0x1780000, above the window entirely
The firmware framebuffer sits inside RAM the allocator hands out ofprint framebuffer_base against the PMM regionFB_IN_RAM=0. Framebuffer at 0x80000000, PMM ends at 0x1bb5d000
The desktop writes past its buffera canary page after it, checked at end of bootguard_broken=0, including on failing boots
The frames are handed out twiceowners and state of every frame in the range, at end of bootbackbuf_shared=0, backbuf_lost=0

The last one was already answered by a counter nobody had connected to this question: frame_take increments double_allocs when it takes a frame that is not FREE or already has an owner, every allocation goes through it including the contiguous path, and the gate asserts it. It reads zero. The case file's stated next step had been answered before it was written.

The detector that was wrong first

backbuf_shared initially asked for owners != 0 and reported 1000 of 1000 frames shared, on every boot. That is the shape of the 3,019 use-after-frees that cost a phase: a detector firing on everything is reporting its own baseline. frame_take sets owners = 1 when it hands a frame out, so one owner is the allocated state. The test is > 1.

What eighteen boots said about the argv defect

It did not reproduce once. The case file records roughly one boot in six; the probability of none in eighteen at that rate is about 3.6%. That is not proof of absence, and it is not treated as one — but the defect cannot be studied at this rate, and the four instruments above now stand as a permanent answer to four of its explanations.

What did reproduce: three boots in ten, byte-identical

reason=invariant_failed:stress_run_did_not_finish

The stress run had finished. It completed 120 rounds and exited zero — SVC_EXIT svc-stress 10 0 is in the same log, the machine reached its CLI, ran commands and halted cleanly. What went missing was the marker, and the log held, identically on all three failures:

[HW][SYS] write(ring3): ^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@=120

Sixteen NUL bytes, then =120. The program calls say("STRESS_OK rounds=", rounds, 1), and STRESS_OK rounds is exactly sixteen characters. Bytes 0–15 of the buffer read as zero; bytes 16–20 read correctly.

say builds its text in char line[128] — on the user stack — and hands that address to write. So this is a user stack page whose first sixteen bytes the kernel did not see.

Byte-identical repetition is what makes this worth chasing over the argv defect. Random corruption is not identical three times, and this project's own rule is to look for a signature present in one state and absent in another rather than reaching for a ratio.

The reading, kept separate from the measurement

frame_take hands a frame out zeroed. A page reading zero where a process wrote is therefore a fresh frame, and the obvious way a process's page becomes a fresh frame is the copy-on-write copy. Sixteen bytes lost followed by four correct would mean the copy landed between two stores — the earlier ones going to the frame that was abandoned, the later ones to the one that was kept.

Consistent and unproved. The next step is to make the kernel say it rather than reason about it: record, for the frame behind a suspicious user read, whether it was copy-on-write resolved during this process's life, and what the old and new physical addresses were. This project's rule that a reason must name a mechanism rather than a situation applies — "read as zero" is a situation.

The check that should have caught it, and why it is not at fault

interleaved_lines runs first, before every other assertion, precisely so nothing is concluded from an untrustworthy log. It reported nothing.

Its docstring already states the limit that let this through: "a split that lands inside a ring-3 write and truncates no number is not caught here." And this is not a split — the console lock was never involved. A NUL in a text write is a different mechanism, and widening the interleaving check to cover it would have conflated the two.

So it got its own check, ring3_writes_with_nul, beside that one and before everything downstream, with self-tests including a healthy line it must not flag. The gate now says ring3_write_corrupted and names the memory manager, instead of stress_run_did_not_finish and naming a program that did its job.

That is the whole value of this entry. Three boots in ten were failing with evidence that pointed at svc_stress.c, which is correct, and would have kept pointing there for as long as it took someone to notice that the marker was mangled rather than absent.

Also fixed: a script that broke the rule it was written to serve

hunt-argv.sh (since replaced by the general hunt-boot.sh, and deleted) kept a serial log only when it found an argv refusal, so a sweep that produced two failures of another kind overwrote both before anybody read them. That is this project's rule about not destroying evidence, broken by the script written to gather it. It keeps every failing boot now.

P6 step 1 - the reverse map

The question every other structure in this subsystem cannot answer: given a frame, which page-table entries point at it?

Nothing could ask it before. The kernel's only way to find out was to scan every address space and trust the result, which is the "reconstruct the truth from the hardware bits" mistake the whole memory-manager rewrite exists to end - and the same mistake that, done twice in slightly different ways, leaked 211 frames a boot in one direction and handed the kernel's own page tables back to the allocator in the other.

Why it is the first item of P6 and not the last

The plan originally had it inside compaction, at the end. It moved because two phases are blocked on it:

  • Compaction (P6.5) moves a frame to recover contiguous space. Moving one means finding every entry that points at it and repointing them. A frame whose holders cannot be enumerated cannot be moved.
  • Swap (P5) evicts a frame, which means unmapping it from everyone. After a fork a frame belongs to several address spaces, so an eviction knowing only one of them leaves the others pointing at a slot that no longer holds their page. P5's own correction note says it waits for this rather than restricting itself to singly-mapped frames - which would be a swap that cannot evict the pages a forking workload actually accumulates.

What it is, and what it deliberately is not

It is not the ownership count. owners says how many, which is what lifetime decisions need and is cheap to keep exact on the hot path of every map and unmap. The reverse map says which, which is what moving and unmapping need and is not on that path. Keeping them separate is the point.

One list head per frame - a direct index rather than a hash, one 32-bit word per frame, a quarter of what the frame descriptor already costs. That removes every question about collisions from a structure whose entire job is to be trustworthy about identity.

The layer never allocates. It is called from inside the address-space layer, which is itself called with other locks held, and a layer that can call back into the allocator from there is a deadlock waiting for the first time memory is tight. It is given a pool once.

Running out is reported, not fatal. exhausted counts it and nodes_peak says what the pool should have been. Losing the ability to move a frame degrades reclaim; failing the mapping that wanted to be recorded would break a program that has done nothing wrong.

It has its own lock, supplied by registration rather than by a weak symbol - the fourth layer here to need one, and for the reason CLAUDE.md gives: a layer with mutable statics and more than one caller locks itself. Its own and not a borrowed one, because sharing the frame lock would deadlock on the first map.

The invariant it exists to make checkable

For a frame the address-space layer owns, the number of holders equals its owner count. The defect this subsystem keeps producing is precisely a mapping that no count knew about - mappers=2, owners=1 - and until now that could only be discovered at a release, one boot in sixteen, a long way from whatever lost the reference.

Verified

  • Nine host-test groups, checking identity rather than quantity: a list that is the right length while naming the wrong mapping is worse than no list, because compaction would repoint an address space that never held the frame and leave the one that did pointing at freed memory.
  • Four sabotage cases in scripts/dev/cases/mm-rmap.txt, each confirmed red, and the tree confirmed green again afterwards.

One of those tests was wrong first and is worth recording: the pool was sized by guessing the node at 32 bytes when it is 24, so it held a third more than intended and the exhaustion case passed without ever exhausting anything. It is sized in bytes now, with an attempt count that is a multiple of the pool rather than a number tuned to it. Same family as the vacuous NTFS cases.

Wired, and audited

Four call sites, chosen by one rule: wherever a reference is taken by publishing an entry, a holder is recorded; wherever a reference is given back by clearing one, the holder is forgotten. The two lines sit next to each other in every case, so an edit that moves one has to look at the other.

  • map_raw, next to the vibeos_frame_get that publishes the entry - and the previous holder forgotten next to the put that retires the old one.
  • unmap, next to its put.
  • The copy-on-write fault's winning side, in both directions: the page's physical address changed, so this address space holds a different frame at the same address. Recording the new holder without forgetting the old one would leave the shared frame looking like it has one holder more than it does, and compaction would then move a page on behalf of an address space that had stopped pointing at it.
  • destroy, as one sweep rather than one removal per entry - which would be quadratic in the size of the address space, and which also means a teardown that failed half way leaves nothing behind for the next tenant of a recycled root.

clone_cow needs no call of its own: it goes through map_raw.

The audit

Every audited fork now compares, for each owned entry, the holder count against the owner count. They are the same quantity counted two ways, so a disagreement means somebody holds a page nothing is counting - mappers=2, owners=1, the defect four investigations chased. Until now it could only be discovered at a release, one boot in sixteen and a long way from whatever lost the reference.

It is skipped when the node pool has been exhausted at all, because a truncated list is shorter than the truth by design and reporting that as a mismatch would turn a reported degradation into a false defect.

rmap_mismatch, rmap_cycles and rmap_missing_remove are on the MUSTBEZERO line and the gate fails on any of them. Verified in both directions: the clean tree passes, and removing the single vibeos_rmap_add from map_raw gives mm_rmap_mismatch=58, mm_rmap_missing_remove=392.

What a boot measures

Zero on every counter, and rmap_peak=1710 nodes against a pool of two per frame - so the pool is oversized by a wide margin and nodes_peak is the number to size it from if that ever matters. 8/8 clean boots.

The wiring was wrong first, and the wedge report named it

The node pool was carved after vibeos_frame_init, and the frame table is carved before it. What protects those pages from being handed out again is the reserve of everything the physical allocator had already given away when the frame layer started - so anything carved afterwards is memory the frame layer believes is free.

It did exactly what that sounds like. User pages were allocated on top of the node pool, the lists were overwritten with whatever the new tenant wrote, and one of them closed into a cycle. The boot stopped with CPU#0 inside vibeos_rmap_add and every other core outside the kernel - a spin with nobody to wait for, which is what walking a circular list looks like from outside.

Two things worth keeping from that. The wedge report named the function on the first failing boot, which is the whole argument for it existing. And the fix is one line moved, not a workaround: the pool is carved beside the frame table where the reserve covers it.

A bound on the list walk was drafted as a safety net and then not kept - bounding the symptom would have made the real defect survivable and quiet, which is the opposite of what this subsystem needs. rmap_cycles exists for a cycle that arrives some other way.

rmap_mismatch, and the reference the TLB quarantine holds (2026-09-11)

A Release boot reported rmap_mismatch=1 with rmap_audit_torn=0 - by this project's rule a disagreement at rest, not a torn sample. Reading the audit next to the unmap path gave a mechanism that fits and needs no defect.

vibeos_vmspace_unmap removes the reverse-map entry at once and hands the frame's reference to release_deferred, the TLB quarantine, which gives it back only when every core has flushed. For that whole time a frame still mapped somewhere else has one owner more than holders - and the owner count does not move, so the audit's bracket of two owner reads cannot tell it from a real mismatch. A fork landing in that window counted the design working.

The backend has a quarantined(phys) hook now, counting the references the quarantine holds, and the audit compares holders + held with the owners. Only references actually held are added: a frame the quarantine does not hold is judged exactly as before, so a real missing holder still counts.

test_fork_audit_counts_quarantined_references holds the window open with a quarantine that never drains - a frame mapped in two spaces, unmapped from one, the other forked - and was red on the old audit. Host tests green, check.sh all green, twelve boots 12/12.

What this does not explain: the clang Debug CI report described in CLAUDE.md came before the quarantine existed. The torn-sample bracket was the response to that one; whether anything else was behind it is not known.

P7's soak property, and the premise it was measured against

Status: the soak property is proved. The frame loss across a boot does not grow with the work: 26 frames at 120 stress rounds, 28 at 12000, measured twice at the high count with the same answer. A leak of one frame per fork would put that in the thousands. The twenty-six are a fixed startup cost, which is a different problem from a leak and is still open.

Getting there took finding a defect in the machine, not in the counter.

The measurement

svc-stress runs randomised rounds of fork, mmap, copy-on-write and pipe work. Its round count is a build knob (-DVIBEOS_STRESS_ROUNDS=) and a second argument for a human replaying a failure, because a failure at round 9000 cannot be reproduced by a default 120-round run.

scripts/dev/soak.sh runs the boot at two round counts and prints both losses. One measurement proves nothing here: twenty-six frames is a fixed cost or a per-round leak, and those are the same number with opposite consequences on a machine that stays up. The comparison is the test.

The defect it found

The first high-round runs came back red and self-contradictory: serial_log_interleaved, mm_rmap_mismatch=1, meminfo_frames_missing, and a loss that grew with the rounds — 26, then 43, then 53. That reads as a leak.

It was not. The log's tail said so: userland_finished at 55.4 seconds, and svc-stress's forks still retiring after it, with the kernel CLI already up.

The wait loop that decides userland is over asked whether any user task was READY or RUNNING. A blocked task — in waitpid after a fork, on a pipe, on a futex — is neither. So a moment when every user task happened to be waiting read as "nothing is running, we are done": the machine printed all user tasks retired with tasks very much not retired, went on to the console, and kmain sampled the frame accounting whose own comment states that every user process has exited by that point.

It stayed hidden because an ordinary boot's programs are short. At 120 rounds the stress run is usually over before the window can open. At 12000 it opened on nearly every boot, and the mid-flight numbers looked exactly like a leak that scaled with the workload.

BLOCKED counts as alive now. ZOMBIE deliberately does not: it holds a slot, not an address space, and the caller goes on to reap it.

The check that was missing

The accounting had a precondition and nothing verified it. That is the same shape as the defect it hid, so the gate now asserts the ordering: FRAMES_AT_USERLAND_DONE printed before STRESS_OK fails the boot.

It is a function with self-tests rather than an inline comparison, for a reason worth recording. Sabotaging the kernel side — putting the blindness back and booting at 12000 rounds — came back green: that run did not happen to open the window. A single red run cannot prove the check fires and a single green one cannot prove it does not, which is this project's own rule about counts applied to a check instead of to a boot. Three synthetic logs can, and removing the comparison turns them red.

So the sabotage case is recorded as environment-dependent, in the same spirit as the AHCI cases that QEMU cannot exercise: "no case exists" and "the case exists and this run could not tell" are different things.

What is still open

The twenty-six frames. They are a fixed cost, not a leak, and the boot gate holds them under a ceiling of 64 so they cannot grow unnoticed. Naming them is its own piece of work.

P5 step 1 — the swap map

Swap has two halves, and they fail differently. Deciding which page to evict is a judgement that can be wrong without being dangerous — the machine is slower. Deciding where it went cannot be wrong at all: a slot handed out twice, or read while free, gives one process another process's memory at some later fault, with nothing anywhere reporting an error at the moment it goes wrong.

So the bookkeeping is a layer of its own, and it knows nothing about pages, address spaces or pressure. It allocates a slot, gives it back, and moves 4 KiB in either direction.

What it guarantees

  • a slot that is handed out is not handed out again until it is freed;
  • a read returns what the last write to that slot put there;
  • a failed write leaves the slot allocated and reports, rather than quietly losing a page something has already stopped mapping.

That last one is the least obvious and the most important. Freeing the slot after a failed write puts it back in the pool while a page still believes its contents are there; reporting success loses the page outright. Losing a page loudly is recoverable; losing it quietly is not.

Choices worth the words

A bitmap and a cursor, not a free list. A free list stores its links inside the thing it tracks, and this tracks space on a disk, so the links would have to live somewhere else regardless. A bitmap over a few thousand slots is one cache line of scanning at worst, and "is this slot allocated?" is answerable without walking anything — which is what makes the refusals cheap enough to keep on every operation instead of only in debug builds.

The cursor matters on the path that runs when memory is short: always scanning from zero would be O(allocated) per allocation with a full-from-the-front area. Once round and no further, so "full" is answered rather than spun on.

The device is a pair of callbacks. The block layer is not portable and this is — the same reason the frame layer takes a map function. Everything in this subsystem that could not be host-tested is where the defects were.

The transfer happens outside the lock. It is a device operation and may be slow, and every other layer here has learned that lesson. The slot is allocated and stays allocated for the duration, so the bit does not need holding.

Verified

Seven host-test groups, of which six are refusals. Five sabotage cases in scripts/dev/cases/mm-swap.txt, each confirmed red with the tree confirmed green again.

One test is arranged deliberately: "a slot is never handed out twice" marks the slots it has seen rather than counting allocations, because a map that returned the same slot every time would still report the right number. The sabotage case that deletes bit_set is what that arrangement exists for.

Another asserts that a refused transfer never reached the device at all — a refusal that still issued the read would leak the previous tenant's page through the driver's buffers rather than through the caller's.

P5 steps 2-3 — page-out and page-in

The order is the whole design

Page-out unmaps first, then writes. The other order loses a store: a process writing between the copy and the unmap would put its value into a frame that is about to be freed, and the value is simply gone — silently, in a page somebody reads back later.

Unmapping first turns that window from narrow into correct. A store in the window faults; the fault finds an entry marked swapped, waits for the write and reads back exactly what was written. Nothing is lost, only delayed.

That order is also why the write-failure recovery has to exist. When the write fails the entry has already changed and there is nothing on disk, so leaving it swapped loses the page for good. Putting the mapping back loses nothing — the frame still holds the contents — and the next fault finds a normal page.

Where the slot lives

In the entry, in the address field, with VIBEOS_PTE_SWAPPED (bit 10) saying so. Only meaningful when the entry is not present, which is what makes bit 10 safe: the hardware never looks at a non-present entry, so the whole of it is the kernel's.

Putting the slot number where the frame number would be is deliberate. The two are never both true, and sharing the bits means a walk that forgets to check the marker is looking at an address that cannot be valid rather than at a plausible one.

What it refuses, and why the refusals are counted

  • Pinned. The same list as compaction and reclaim.
  • Shared. After a fork a frame belongs to several address spaces, and evicting it means changing all of their entries — a different operation. Counted, because a swap that cannot touch shared pages will never reclaim what a forking workload accumulates, and that should be a number rather than a silence. It is the reason the plan says P5 waits for the reverse map.

Verified

Five more host-test groups (fourteen in compact_tests.c now) and five more sabotage cases, each confirmed red.

One of them walked through a green test first, and the reason is worth keeping. "Page-in accepts a present entry" did not fail: with the guard removed, page-in derives a slot number from the frame address, which is far outside the swap area, so the transfer failed anyway — the test passed for the wrong reason. It now asserts that no read was attempted, which is the same property the swap map asserts for an unallocated slot. That is twice in this phase that a test has been right about the outcome and wrong about the mechanism.

The round-trip test scribbles over the old frame before paging back in, so a page-in that read from memory instead of from the slot would be caught rather than flattered by the frame happening to still hold the right bytes.

P5 step 4 — the anonymous tier, and the chain

Reclaim has a second tier now: anonymous pages, to swap, tried only for the shortfall after the clean tier. Second because the order is by cost and this one costs a write; only for the shortfall because a reclaim that took from both when the first had already satisfied it pays for pages nobody asked for.

The test that matters is the one about the joins

Every other test in this subsystem exercises one link. The end-to-end one asks whether the links are connected, which is a different question and the one that has caught the most here: reclaim can be right, page-out can be right, and the machine can still never swap anything because nothing ever calls the second from the first. That is the same shape as the scheduler's unread quantum and the watermarks that were configured and consulted by nobody — three times in this project, now, that a working mechanism was simply not wired to anything.

So: pressure, reclaim choosing the anonymous tier, page-out, a fault, page-in, and the bytes. Each page carries a different byte, and the check is by index, so a page-in that mixed two slots up is caught rather than a page that merely comes back wrong.

Two tests that were right about the outcome and wrong about the mechanism

That is now three in this phase, and all three were found by sabotage rather than by review.

"The anonymous tier is asked for the whole amount, not the shortfall" did not fail. With no clean source in the harness, freed was zero when the anonymous tier was called, so the shortfall and the request were the same number and nothing could tell them apart. The harness now gives the clean tier a stock of two against a request of four.

"Page-in accepts a present entry" did not fail either, for the reason in the previous section.

The pattern is worth naming: a test that reaches the right verdict through an arrangement where the defect cannot show is indistinguishable from a test that works, until somebody breaks the code on purpose.

The state this kernel is actually in

There is no swap area on the boot media, so no anonymous source is registered and page-out is never called on the real machine. That is a configuration rather than a gap, and it is visible rather than silent: skipped_no_swap counts every page reclaim was not allowed to take, and a test asserts that it does. A gate reading zero there would be reading a machine that had nothing to reclaim, not one that is working.

Where swap lives — the structure for configuring it

The swap map hands out slots and moves 4 KiB in and out of one. It deliberately does not know where a slot is: that is a configuration question, and the answer differs between a machine with a spare partition and one that has to borrow space inside a filesystem. kernel/mm/swaparea.c is the translation, and it is the only code that turns a slot number into a block address.

The property it exists to guarantee

A transfer never touches a block outside the area.

Everything else there is arithmetic. That one is safety: swap sits on the same device as the filesystem, so an area that miscomputes its bounds does not fail — it writes a page of somebody's memory over a directory, and the damage is found when the machine next boots.

A driver cannot catch it. A driver that refuses an out-of-range LBA protects the disk; it cannot protect the filesystem, because the filesystem's blocks are perfectly valid addresses. This layer is the only place it can be stopped.

The tests are weighted accordingly: the fake device records every LBA it is asked for, and the assertions are on the range touched and on the sectors outside the area still holding what they held — not on return values, because every one of those writes would succeed.

Two kinds, described the same way

A partition is a range of sectors belonging to nothing else: slot n is at first_sector + n * 8, one multiplication, and the area cannot move underneath itself.

A file is space borrowed from a filesystem, which is what a machine with no spare partition has. Its cost is that a file need not be contiguous, and a swap that assumed it was would write pages into whatever lies between its extents. So a fragmented area is refused and counted — a restriction that can be checked rather than a race that has to be reasoned about. Following an extent list is a later change and it belongs here, not in the swap map.

Both are described by first sector and length rather than by name and offset. By the time this layer is configured somebody has already resolved the file to the blocks it occupies; carrying a path would mean this layer had to know about filesystems in order to answer "which sector is slot 12". The path is kept only so a diagnostic can say where the area came from.

Configuring one later

Fill in the descriptor and nothing else. The map, page-out, page-in and reclaim's anonymous tier are built and tested above it. The kernel already asks at boot and prints the answer:

[MM] SWAP_AREA slots=0x0000000000000000 (none configured on this media)

NONE is a state rather than a failure, and it is counted separately from an area that was offered and rejected — only one of those is somebody's mistake. The anonymous source stays unregistered until an area exists, so reclaim keeps counting in skipped_no_swap exactly what having no swap costs, rather than appearing to work.

Verified

Eight host-test groups and four sabotage cases confirmed red. A fifth is recorded in scripts/dev/cases/mm-swaparea.txt as unverifiable here: removing the out-of-range check in area_io does not turn the tests red, because configure sets the slot count and the map's slot count from the same division, so through the public API they cannot disagree.

The guard stays — one comparison, and it is what would catch a future caller that re-initialises the map without reconfiguring the area — but it is defence in depth rather than something this suite proves. Making it testable would mean recomputing the end sector independently, at the cost of a second piece of arithmetic that has to agree with the first, which is how two structures come to disagree about the same fact. One source of truth was judged worth more than one more red case.

Verified

Sixteen host-test groups in compact_tests.c and thirteen sabotage cases in scripts/dev/cases/mm-swap.txt, each confirmed red with the tree confirmed green again.

Swap runs on the real machine

Status: the swap path is live and gated. One gap remains and is named below.

The memory manager's P5 — swap map, page-out, page-in, reclaim's anonymous tier — had been built, host-tested and sabotage-verified for a phase, and had never executed a single instruction on a booting machine. Every boot said so:

[MM] SWAP_AREA slots=0x0 (none configured on this media)

That is the state this project distrusts most: "the driver nobody runs is the one that ships broken". It now says:

[MM] SWAP_AREA slots=0x800 first_lba=0x4bb8 sectors=0x4000 contiguous=0x1 (swap file accepted)
[MM] SWAP_ROUNDTRIP swap round trip OK

Two thousand and forty-eight slots, eight megabytes, on a file.

A file first, a partition second

A contiguous file on the volume already mounted, because that is the shortest path to making the stack run, and because a file is what a machine with no spare partition has to fall back on anyway. The partition follows and is described by the same structure — vibeos_swap_area_t has carried both kinds since it was written.

scripts/make-swapfile.py stages eight megabytes of zeroes on the boot medium. Written in one go rather than grown: the contents do not matter, the extent does, and allocating the whole length at once is what gives a filesystem the chance to lay it down as one run. It is only a chance, which is the next point.

Contiguity is checked, not hoped for

vibeos_x86_64_fat_file_extent resolves a path, walks its chain, and reports the first sector, the length in whole clusters, and whether the walk ever jumped. hw_swap_bringup refuses a fragmented file outright.

This is the one place where being approximately right is not an error but corruption. The swap file shares a volume with everything else, so an area that spanned a gap would not fail — it would write a page of some process's memory over another file's data, and the damage would surface at the next boot as a program that is quietly wrong, with nothing to point at.

Two smaller decisions in the same spirit. The length comes from the clusters the chain actually covers, not from the size in the directory entry — those differ, and this project has already had one defect from trusting a declared size. And a chain walk that hit a table error reports failure rather than a short contiguous file, because fat_next_cluster returns the end-of-chain marker for a failed read, which is the same value a healthy last cluster returns.

The round trip, and why half of it would have been useless

Everything below the area was host-tested against a memory-backed device. That proves the arithmetic and proves nothing about this machine's disk, so the boot now writes a page through the area, reads it back, and compares.

That check alone is weak, and saying so is the point: a write and a read that use the same wrong address agree perfectly. An area whose first sector is off by a cluster passes it while quietly writing over another file.

So the bytes are then looked for through the filesystem — SWAPFILE.BIN is opened by name, which resolves its own chain and never consults the area's stored first sector. If the pattern is not at the front of the file, the area is not pointing at the swap file, whatever the round trip said.

Confirmed by breaking it. Shifting area.first_sector by eight sectors:

[MM] SWAP_ROUNDTRIP swap round trip FAILED: area is not the file
reason=invariant_failed:swap_roundtrip:swap_round_trip_FAILED:_area_is_not_the_file

The first half passed. Only the second half caught it.

What was actually missing from the machine

Nothing in kernel/mm/ needed changing. What was absent was every connection to it:

  • vb.swap_write and vb.swap_read were null, and vibeos_vmspace_swap_out checks for a null hook and gives up quietly — so the absence was not an error anywhere, it was a subsystem that could not be reached;
  • the page-fault handler had no idea what a swapped entry was, so a page sent to swap could never have come back;
  • vibeos_reclaim_set_anon_source was called by nobody, and no code anywhere decided which page should go.

The last one is kernel/mm/anon.c, new here: a resuming hand over the frame table that takes allocated, unpinned frames with exactly one owner and one mapping. The hand matters — scanning from zero every time would examine the same low frames on every reclaim and reach the rest only under severe pressure, which is the shape of a mechanism that looks like it works and covers a fraction of memory.

The page-fault change is small and easy to get wrong: the swapped-entry test runs before the copy-on-write test, because a swapped entry has no present bit and the present-and-write condition would otherwise reject it — killing a task for touching memory it legitimately owns.

The gap, stated plainly

freed_anon is zero on every boot, because nothing runs the machine short of memory. The anonymous tier is registered, its counters are printed, and the boot gate asserts the ones that must be zero — but no boot has yet driven a page out through vibeos_anon_reclaim and faulted it back in.

So what is proved today is: the area is real, it points at the file, and a page survives the trip to the disk and back. What is not proved is the eviction path under pressure. svc-press is the workload that would do it and it is not started at boot, because at twenty megabytes it trips a separate defect that predates all of this (mm_reclaim.md).

Naming that gap rather than letting freed_anon=0 sit unremarked is the whole of the difference between this and what came before it.

Swap-out against everything else (M-056)

Status, 2026-09-24: fixed, and proved on the host only. Host-tested and sabotage-verified; the machine boots and soaks green with it - but no run on a machine has ever reclaimed an anonymous page, before this change or after it. See "How it is proved" and "What it does not cover".

The report, and what reading the code found

An external review said, in one line, that vibeos_vmspace_swap_out "does not yet have the final re-verification CAS". The line alone did not say what the exchange should protect. Reading the code, the answer was larger than the report:

Reclaim runs from the allocation path - hw_alloc_page_admitted, on any core, from any allocation, possibly while the allocating process holds its own locks - and it evicts a page from an address space that is not its own, holding none of that process's locks. So three things could happen to the victim while its page was being evicted, and nothing stopped any of them:

  1. It exits. Teardown frees the page tables the eviction is walking, and the eviction's compare-exchange then writes into a page the allocator has handed to somebody else.
  2. It unmaps the page. munmap saw an entry that already said "swapped", so it freed the slot - while the eviction was still writing into it, for the next eviction to be given.
  3. It touches the page. The entry said "swapped" before the write, on the argument that a store would fault and be "delayed rather than lost". It was not delayed: the fault found a swapped entry and read the slot - before the write had put anything in it.

The anonymous tier's own state (g_hand, its counters) was also unlocked, with more than one core able to reclaim at once.

None of this was reachable in the boot gate: its boot never comes under enough pressure to reclaim (anon_scanned=0). The nightly soak was believed to drive reclaim and does not either - see the last section - and no host test had ever called vibeos_anon_reclaim at all.

The protocol now

Claims keep the victim's page tables alive (vibeos_rmap_claim_sole, vibeos_rmap_unclaim). Reclaim takes the frame's holder only if there is exactly one, and records a claim on that holder's root under the rmap lock - the lock teardown takes first. vibeos_rmap_forget_root, which teardown calls before it frees anything, removes the root's holders (so no new claim can start) and then waits for the claims already taken. The wait goes through a hook the arch supplies, which bounds it with a named panic like every other wait here. A deadlock was ruled out before this was written: teardown never runs on the address space it destroys, so the evicting core's shootdown targets nobody the waiting core is blocking.

The page stays present while it is written. A writable page loses its write bit and gains VIBEOS_PTE_SWAPOUT (bit 52), and every core is told; a read-only page is left alone. The page is written. Then one compare-exchange, from exactly the marked entry to the swapped one, commits. Anything that touched the entry in between makes it fail and the swap-out is abandoned with nothing lost:

MeanwhileWhat happens
a storefaults; the fault gives the write bit back (swap_out_cancelled); the commit fails
munmapclears the entry; the commit fails; no entry ever named the slot
forkconverts the marked page to copy-on-write in both; the commit fails
mprotectrebuilds the entry without the marker; the commit fails
compactionmoves the frame; the commit fails

The marker is honoured in the three places copy-on-write taught this kernel about: the fault handler (cancel, user or kernel mode), hw_user_range_ok (writable - a syscall writing a buffer being evicted must not get EFAULT), and every path that rebuilds an entry. Two of those rebuilds were subtle enough to be wrong without a test: mprotect keeping the marker would give a write bit back to a page made read-only (CLAUDE.md's bit-9 defect again), and fork sharing a marked page "as it stands" would leave the child read-only without copy-on-write, killed on its first store.

How it is proved

  • tests/kernel/rmap_tests.c, test_claims: only a sole holder is claimed; a teardown with no claim does not wait; one with two claims waits until both are released; a released root cannot be claimed; an unmatched unclaim is counted; a full table refuses.
  • tests/kernel/compact_tests.c: six groups in which the in-memory swap disk runs "another core" in the middle of the page's write - a look, a store, an mprotect, an munmap, a fork - plus a read-only page; and the first host test of anon.c, which evicts through the claim on both the refused and the evicted path and then tears the owner down without waiting.
  • Sabotage: mm-swapout-protocol.txt (6), mm-rmap-claims.txt (3), mm-anon-claims.txt (2) - all eleven red, each on its own named check.
  • Two re-pointed cases in mm-vmspace.txt (protect drops the ownership mark; fork leaves the parent writable), whose anchors this change moved - red again on their own checks.
  • The machine: check.sh all green (gcc and clang, warnings 0, boot), and the soak at 120 and 12,000 rounds green with nothing lost per round. That shows the change breaks nothing that runs. It does not show the protocol working on a machine, because of the next section.

What it does not cover

Anonymous reclaim has never run on a machine. The soak was expected to be the run that exercises it - the nightly job is called "Memory + Swap Soak" - and it is not: at 12,000 rounds it reports anon_scanned=0 freed_anon=0, the same as the boot gate. The machine never falls below its low watermark, so vibeos_reclaim_run is never called, and the swap area's only traffic is the eight-sector self-test round trip. Everything above about the protocol on a machine - the fault handler's cancel in ring 0 and ring 3, the arch's bounded wait, hw_user_range_ok - is therefore correct by review and by the host tests, not by a run. A workload that drives the machine into reclaim is what would change that, and it is the next thing this area needs.

It is blocked (2026-09-24): svc-press, the load that would do it, still stops the machine at 28 MiB - long before reclaim starts - on a defect of its own, written up in mm_reclaim.md.

Found by reading the soak's own output for the counters this change added, which read zero; the soak's verdict line has no field for reclaim and was green throughout.

munmap used to hand back a frame other cores could still reach

The source admitted this for as long as it existed. The comment at the end of munmap said a shootdown "belongs here too, and is deliberately absent", gave the reason, and stopped there. An external review found the admission and was right to call it a defect rather than a note — the first review of six to land in the live path.

Its line numbers pointed at the copy-on-write fault handler rather than munmap. The mechanism it described was exactly right, which is why the location did not matter:

CPU 0                         CPU 1 (sibling thread, same address space)
munmap(P)
  clear PTE
  invlpg          <- this core only
  frame_put       -> allocator
  ...reallocated                 still holds VA -> F in its TLB
                                 writes through it -> corrupts the new owner

Verified before being believed

vibeos_vmspace_unmap calls g_be.invlpg(va) — one core — and then vibeos_frame_put. Both confirmed by reading, not remembered.

One thing the review did not have: the harm is not entirely silent. Freed pages are poisoned and the poison is checked at hand-out, and poison_hits is a must-be-zero the gate asserts. So the free → stale write window is covered. What is not covered is free → reallocate → stale write, where the poison check has already passed. Narrower than the review's diagram, and real.

Why the obvious fix stays rejected

A synchronous shootdown was tried here and measured: two runs in twenty-four failed with tlb_acks below shootdowns — a core that never answered. syscall clears IF (SFMASK is 0x200), so a core inside a system call cannot take the IPI until it returns to ring 3, and munmap runs far more often than fork; the stress run alone calls it 120 times. Trading a rare correctness gap for a frequent stall is the wrong trade.

The third option, which the comment never considered

The comment weighed synchronous shootdown against nothing. The review proposed deferred reclamation, and the constraint that killed the shootdown — IF masked during a syscall — does not apply to it, because nobody waits for anybody.

The frame is parked and released once every other core has loaded CR3. Two facts make that a valid quiescence point, and both were checked rather than assumed, because the argument dies silently if either changes:

  • this kernel enables no PCID and marks no page global, so writing CR3 flushes that core's whole TLB;
  • every context switch loads CR3 unconditionally in hw_task_load_cpu_state.

Both are now named in hw_cpu_t.cr3_generation's comment, where somebody enabling PCID would have to read them.

This is a pattern here rather than an invention: dead_kstack_base already parks the kernel stack of an exited task until its core is provably running on another.

The hole in the first version, 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 on its own — and a sibling thread spinning on another core is both the thing holding the stale translation and the thing that would never advance.

Frames would have piled up until the quarantine overflowed and fell back to the racy release, precisely under the workload this was written for. The easy case would have been fixed and the hard case would have degraded to the old behaviour, and no green boot would have told them apart.

Closed by having every core flush on its next timer tick while anything is parked — the cost is paid only while frames are waiting, and a TLB flush per tick is what a context switch already does on a kernel with no PCID.

beforeafter
live peak, slots of 512175~45

Measured

Ten boots, four vCPUs:

frames deferred~320 per boot
releasedequal to deferred, every boot
overflow0, every boot
live peak35–66 of 512

frames_leaked stays zero, which is a separate assertion and the one that would catch the quarantine holding on to something.

What is gated, and what is not

Superseded by M-061 below: the assertion described here is gone, because since then whether a boot parks anything depends on timing.

munmap_never_deferred_a_frame asserts deferred is non-zero. The defect is a race that a handful of boots cannot produce on demand, and every earlier claim in this project that such a thing was fixed rested on a handful of green boots. What a boot can assert is that munmap took the safe path at all — a mechanism that silently stopped running would otherwise leave every boot green with the defect exactly as it was. Same argument that put tlb_shootdowns under the gate.

overflow is deliberately not asserted zero. Falling back is precisely what the kernel did before the quarantine existed, so it is never worse than the status quo; the number says how much of the window is still open, which is worth reporting and wrong to fail on.

Sabotage: unregistering release_deferred gives reason=invariant_failed:munmap_never_deferred_a_frame,tlb_quarantine_never_released_anything.

And it caught an omission of its own on the way: the three new reasons had no case file, so check-assertions-covered.py went red at 111 against a baseline of 108 — the check that matches every reason the gate can emit against the sabotage cases, doing its job on the commit that added three.

M-061: parked only when somebody could still hold it

The quarantine parked every unmapped frame until every other core had loaded CR3 - including frames of a single-threaded process that no other core had ever run. Nothing exposed that until svc-press (M-060) unmapped 64 MiB in a loop: the 512 slots filled faster than the other cores flushed, and each frame past that was leaked on purpose (H-015). tlbq_overflow=2478, userland_frames_lost=2486

  • ten megabytes gone for the rest of the boot.

Only a core that has the address space loaded can hold a translation from it. So each core now publishes what is in its CR3 (hw_write_cr3), in two fields, because the answer must be conservative in both directions:

  • loading_cr3 is written before the load, with a full fence. The unmap clears the entry (a locked compare-exchange) and then reads this; the loading core writes it and then walks the tables. One of the two always sees the other: either the quarantine sees the core coming, or the core's walk finds the entry already gone.
  • loaded_cr3 is written after the load. A core switching away is still seen as holding the space until its flush has happened.

A frame is released at once when no other core has the space in either field, and otherwise parked against only the cores that do - the drain no longer waits for cores that had nothing to flush.

Measured: svc-press's 16,856 unmaps are released immediately apart from a couple of dozen, tlbq_overflow=0, frames lost 8 (ceiling 64). The thread tests still park 17-36 frames a boot, the case the quarantine was written for.

The gate had to change, and why

Whether a boot parks anything now depends on whether a sibling thread happened to be running on another core at the instant of an unmap. Two boots in ten parked nothing - correctly - and munmap_never_deferred_a_frame failed them. An assertion on a timing property is a gate people learn to ignore, so it is gone. In its place, two things that do not depend on timing:

  • munmap_bypassed_the_quarantine: munmap reached the decision at all (immediate + deferred + overflow is non-zero). svc-press unmaps 64 MiB every boot, so zero means the hook has gone.
  • TLBQ_SELFTEST: after userland, the kernel releases one frame against a space another core has loaded - it must park and then drain - and one against a space nobody has loaded - it must go back at once. Both constructed on the real per-core fields, so the decision is exercised every boot rather than when the scheduler happens to arrange it.

Cases: scripts/dev/cases/mm-tlbq-holders.txt.

What it does not prove

The ordering of the two publications is not gated. Writing loading after the load, or loaded before it, opens a window of a few instructions in which a stale translation is missed - a race no boot produces on demand, which is the same limit the original defect had. It is argued in the source, not tested.

A second gap, in the mechanism that was supposed to close the first

The same reviewer came back with a finding that is distinct from the munmap one and survives its fix. They were right, and it is worse than they stated.

hw_tlb_shootdown counted a timeout, logged it, and returned. Every caller has already narrowed a permission or removed a mapping by the time it runs, so returning quietly means a core keeps writing through a permission that has been revoked — into a page a fork has just shared, with no copy-on-write fault.

The part the review understated: no caller could have checked. The backend hook is void (*shootdown)(uint64_t root_phys). There is no channel. Its five call sites in kernel/mm/vmspace.c are not failing to check a return value; there is no return value to check.

Why the fix that closed munmap does not transfer

Worth stating explicitly, because the two findings look like one:

munmap gapshootdown timeout
resource in dangera frame, about to be reissueda permission, already narrowed
can it be parked?yes — that is the quarantineno — there is nothing to hold
can the operation be undone?not neededno: the entry must be written before the shootdown is asked for, or there is nothing to invalidate

So "do not complete the operation" is not available by the time the timeout is reached, and deferred reclamation has nothing to defer.

What was done

The only remaining choices were to keep waiting or to stop. A core that has not answered in two hundred million spins is not about to, so it panics, naming the reason. That is this project's own position in its own words — the difference between a machine that stops and a machine that says why — and a silent memory-corruption path is strictly worse than a named halt.

Recorded as untested. Making a core genuinely fail to answer for two hundred million spins is not something this harness can arrange, and lowering the bound to force it would test a different machine. What is known: tlb_timeouts reads zero on every boot of this tree, and it is a counter that has moved historically — about three boots in thirty-two, back when the shootdown broadcast to every core instead of only those running the address space — so it is not one of those numbers that has only ever been zero.

If it ever fires, do not soften it. The fix it points at is the one the munmap comment already names: stop masking interrupts for the whole of a syscall, so a target can answer.

And a mitigation that already existed, which does not change the verdict

The boot gate matches the string shootdown timed out and fails the boot. That is a detector, not a fix — the operation in that boot has already completed incorrectly — and it only exists in CI. It is worth knowing because it means this has not been happening silently in the runs measured here.

Networking Stack Progress

Status: In Progress (runtime IPv4 baseline plus portable route/firewall data-path enforcement verified) Last review: 2026-08-26

Implemented

  • Network runtime/service scaffolding in user/net/network_service.c.
  • Socket lifecycle primitives in user/net/socket.c.
  • Basic policy-aware bind checks integrated with security policy layer.
  • Service lifecycle integration under service manager control path.
  • Deterministic packet-path simulation API (vibeos_net_simulate_path) with latency/drop counters.
  • Extended network telemetry snapshot (vibeos_net_stats_ext) including simulated ticks and drops.
  • A runtime TCP/IP baseline in kernel/net/inet.c is connected to the virtio-net driver, with Ethernet, ARP, IPv4, ICMP, UDP, TCP, DHCPv4 and DNS handling for the QEMU path.
  • User networking programs and the QEMU CLI smoke path exercise the on-metal network route rather than only the host simulation model.
  • The receive path rejects invalid TCP checksums and non-zero invalid UDP checksums before dispatching payloads; TCP handshake transitions require an acknowledgement for the transmitted sequence number.
  • Host regressions cover DHCP OFFER/ACK processing, DNS A response parsing, TCP retransmission and close paths, plus malformed L4 checksum rejection.
  • DNS keeps a bounded eight-entry positive cache with TTL clamping; cache hits are resolved without a new packet transmission.
  • A portable routing/firewall policy contract (include/vibeos/net_policy.h, kernel/net/net_policy.c) now provides longest-prefix IPv4 route lookup and default-deny stateful flow admission; host tests cover route precedence, ingress denial, allowed reply flow and protocol denial.
  • vibeos_inet can attach the portable policy to its IPv4 data path: transmit requires a matching route and egress rule, receive validates TCP/UDP endpoint ports before protocol dispatch, and an accepted egress flow admits the reverse reply. Host regression covers ingress denial and a per-port UDP allow through sendto; virtio-net and syscall control-plane wiring remain separate follow-up work.
  • Stateful policy flows now have bounded idle garbage collection (vibeos_net_policy_expire_flows) and are reclaimed from the portable network poll path, preventing stale entries from exhausting the fixed flow table. Host coverage verifies an accepted reply is denied after expiry.
  • Portable sockets now carry an explicit owner PID and expose owner-checked close operations; host coverage rejects cross-process close attempts. Syscall propagation and automatic process-exit cleanup remain pending.
  • The x86_64 socket syscall assigns the current PID as owner, and process exit now invokes forced owner cleanup before retiring file descriptors, reclaiming all owned sockets without waiting for TCP close timers. Broader syscall capability checks remain pending.
  • Route and firewall policy entries now support validated removal with bounded-table counters, allowing a control plane to replace configuration without rebooting or leaking slots; transactional updates and syscall capability gates remain pending.
  • Mbed TLS 4.2.0 is pinned as a submodule and exposed through a narrow hosted adapter (vibeos_tls_*). The adapter is built into its own vibeos_tls target, linked only by the host tests: the freestanding kernel image links vibeos_user_core, so keeping the hosted crypto stack out of that library is what structurally prevents it from reaching the image. In 4.x the crypto implementation lives in TF-PSA-Crypto, a nested submodule building tfpsacrypto; the build asks which target exists rather than naming one, because CMake turns an unknown target name into a raw linker flag and the failure then surfaces at link time instead of configure time.
  • The dependency is optional at build time. A tree without the submodule configures and builds with TLS absent, and the adapter reports vibeos_tls_runtime_available() == 0; the host test asserts the adapter's contract in both configurations. Builds that must ship TLS set -DVIBEOS_REQUIRE_TLS=ON and fail loudly if the submodule is missing, which is how the Linux CI matrix is configured.

Wave 1 (runtime enforced)

  • UDP queues multiple datagrams per socket, framed in the receive buffer; each recvfrom drains exactly one, oversized datagrams truncate without leaving a partial second delivery, and a full queue drops the newest and counts it.
  • TCP holds out-of-order segments (four slots per socket) and releases them when the gap is filled, instead of discarding anything not at rcv_nxt and forcing wholesale retransmission. Out-of-order arrivals produce duplicate ACKs.
  • Connections that are closing are reclaimed: TIME_WAIT and an unanswered FIN both arm a bounded deadline, so a peer that goes silent cannot pin a socket slot for the life of the system.
  • DHCP leases have a lifetime: options 51/58/59 are honoured, the client renews at T1 and rebinds at T2, gives the address up at expiry and returns to discovery, and a NAK drops the lease immediately.
  • DNS queries retry a bounded number of times and then fail, so a caller cannot wait forever on a server that never answers.
  • Sockets are owned by their process: a task that exits with connections open has them closed as it is retired.

Pending

  • Concurrent DNS queries (the API still resolves one name at a time) and negative caching.
  • Complete TCP error handling: RST generation for unknown connections, window probing, congestion control.
  • Firewall policy, routing table and robust recovery semantics beyond the portable IPv4 path.
  • The routing/firewall contract is not yet wired into virtio-net or the socket syscalls; process ownership, rule audit events, flow expiry and IPv6 policy remain pending.
  • Ring-3 TLS service integration: entropy source, trust store, TCP callbacks and QEMU TLS handshake validation are intentionally pending; the current adapter is not a guest TLS implementation.
  • Packet-path performance instrumentation, queueing policy and concurrency hardening.

Security findings

M-005: a RST without a sequence check (fixed 2026-09-11)

tcp_input found the socket by address and ports and, on RST, closed it - with no look at the sequence number. Anybody able to put a packet on the path could end a connection without knowing where its byte stream was. And tcp_lookup also matches a listening socket, so a RST to a listening port closed the listener itself.

The rule now is RFC 5961 section 3.2, in tcp_input_rst:

  • LISTEN: nothing to reset; ignored.
  • SYN_SENT: acceptable only if it acknowledges our SYN. That is how a connect to a closed port is refused, so that case still ends at once.
  • otherwise: seq == rcv_nxt resets; anywhere else in the receive window gets a challenge ACK naming rcv_nxt (a genuine peer answers with the exact RST); outside it is dropped silently. The window is the free receive buffer, and the offset is unsigned modular arithmetic, so a number just behind rcv_nxt wraps to a large offset and is dropped instead of read as in-window.

The RST decision also moved ahead of the send-window update: a segment that is not believed changes nothing.

test_inet_tcp_rst_needs_sequence was written first and failed on the old code. It asserts the state and what was sent at each step, because "still established" alone passes a stack that ignores every RST. Host tests green, 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.

What this does not cover: an attacker who can see the traffic reads rcv_nxt off the wire. Sequence checks stop blind injection; on-path needs authentication above TCP.

H-008: the initial sequence number was the clock (fixed 2026-09-11)

connect used 0x1000 + now_ms and a listening socket 0x2000 + now_ms. Whoever could forge a packet could also forge the ACK that completes a handshake, and then knew the sequence number data would be accepted at - so a blind attacker could both establish a connection and inject into it.

Now RFC 6528: ISN = now_ms/4 + SipHash-2-4(local ip, remote ip, local port, remote port; secret), in tcp_isn, used by both paths. The secret is one per stack, vibeos_inet_set_secret, because this portable layer has no entropy of its own; M-007 and H-009 will derive their identifiers from the same secret.

test_inet_tcp_isn_depends_on_secret was written first and failed on the old code. It cannot assert randomness, so it asserts the properties that matter: the same key, time and tuple give the same ISN (the test is about inputs, not noise); a different key or a different tuple gives a different one; and it is not the bare clock value. Both directions are tested.

The secret is only as good as its source, and under the boot gate it is not good. The arch layer takes it from RDRAND when CPUID advertises it and otherwise from the TSC mix that AT_RANDOM already uses, which that function's comment calls what it is. QEMU's default TCG CPU has no RDRAND, and every gate boot logs [NET] stack secret from tsc mix (weak: no entropy source on this cpu). The structure is right and a real source slots in at one call; the kernel still has no entropy source, and that is its own open item.

Also not covered, as for any ISN scheme: an attacker on the path reads the ISN off the wire.

Host tests green, check.sh all green, twelve boots: 12 pass, 0 fail.

M-007: a DNS answer from anybody, for anything (fixed 2026-09-11)

dns_input accepted any datagram to the fixed local port 0xC353 whose id matched the pending query. udp_input did not pass it the sender, so it could not have checked one; the question in the answer was skipped, not compared; and the id advanced by 0x1235 from the same start every boot. An attacker who could put one packet on the path answered first, and the address went into the cache for its TTL.

Now an answer is believed only if it comes from the configured server, from port 53, has the response bit, carries the pending id, and has exactly one question that is the name being resolved, type A, class IN (labels compared without case). The id and the source port are drawn per query from SipHash over the stack secret, the name, a query counter and the time; the port avoids any bound UDP socket, whose datagrams the resolver would otherwise take. A blind spoofer now has about 30 bits to hit instead of none - and, as the H-008 section says, the secret under TCG comes from the weak source.

The test, test_inet_dns_reply_must_match_query, reads the id and port from the query as captured on the wire, the way a server or an attacker sees them, and checks the wrong sender, the wrong port and the wrong question separately before the genuine answer. An older test answered to the fixed port and now answers to the port the query came from.

Two things went wrong on the way, and both are worth knowing. The first version of the test was red for the wrong reason. The DNS server it configured was not the address inet_seed_arp teaches, so the one frame captured was the ARP request for it; the "id" and "port" read from that frame were zero, every reply went to port 0, and the test failed at the genuine answer on the old and the new code alike. A step-numbered run showed it. The server is the gateway in that test now, and the test checks that the captured frame is a UDP datagram to port 53 before reading from it. Re-run on the unfixed code it fails where it should - the wrong sender accepted, id=0x1235 port=0xc353 - and passes on the fixed code.

The second: restoring the fixed inet.c with a file copy kept the copy's older timestamp, the build judged the object up to date, and the "fixed" run measured the red code. The step numbers gave it away - the old identifiers were still there. The sources were touched and the run repeated.

Host tests green, check.sh all green, twelve boots: 12 pass, 0 fail.

H-009: a DHCP reply from anybody (fixed 2026-09-11)

The xid was 0x56494245 mixed with two bytes of the MAC, and dhcp_input checked it, the magic cookie and the state - nothing else. Any host that could put a datagram on the segment could answer the DISCOVER, send an ACK to a renewing client out of nowhere, and set the address, gateway and DNS. Worse than reported: a NAK from anybody, in any state, dropped the lease and restarted discovery - one datagram took the machine off the network.

Now the xid comes from the stack secret, anew for each transaction, and a reply must come from the server port, be a reply, and carry this client's hardware address. An OFFER is taken only while discovering and only if it names its server. The ACK to a REQUEST must come from the server chosen and give the address offered; a renewal ACK must come from the server holding the lease (any server while rebinding, as RFC 2131 allows) and give the address in use. A NAK must come from the server being dealt with.

What no client can close without authentication, and this does not claim to: an attacker on the same segment sees the broadcast DISCOVER - xid included - and can answer first with an OFFER that passes every check. The change narrows the attack to that race and removes it for anyone not on the segment.

test_inet_dhcp_reply_must_match_transaction reads the xid off the captured DISCOVER. It was run red twice: as written, where it fails at the first check because two stacks with different secrets produced the same xid; and with that check switched off, where it still fails, on an OFFER addressed to another client being accepted - so the transaction checks can fail on their own. Two existing tests gained what RFC 2131 requires of a server and they had left out: the client's hardware address in every reply, and the server identifier in the ACK. They pass on the old code as well.

Host tests green, check.sh all green, and the gate's DHCP lease came up as before (ip=10.0.2.15 gw=10.0.2.2 dns=10.0.2.3). Twelve boots: 11 pass, 1 fail

  • the THREADS four-worker crash family, nothing to do with DHCP; recorded in boot_repeatability.md because it recurred after the exit-window fix.

M-008: any ARP packet could repoint the gateway (fixed 2026-09-11)

arp_input inserted every sender it saw, and arp_insert overwrote an entry it already had. One gratuitous ARP from any host on the segment claiming the gateway's address, and every packet to the outside went to that host.

ARP has no authentication, so a host cannot know who is right; it can refuse traffic nobody asked for. arp_request now records the address asked for and accepts a reply to it for two seconds. A reply to that request may add or change an entry. A request addressed to this host may add an entry the stack does not have - answering the request needs it - and never change one it has. Everything else is ignored. So that a neighbour whose hardware address really changes is still relearned, an expired entry is used and refreshed with a request on the next send, and traffic never waits for the answer.

What remains, stated: an attacker who answers a request before the real host does, and one that teaches the cache a host before that host has spoken.

test_inet_arp_cache_not_overwritten checks where traffic actually goes - the destination MAC of a ping through the gateway - after a gratuitous ARP, an unsolicited reply, and a request sent in the gateway's name, and that a solicited reply for a new host is still learned. Red on the old code at the gratuitous ARP. The test seed inet_seed_arp was an unsolicited reply, which the fix rightly ignores; it is a request from the gateway for this host now.

Host tests green, check.sh all green, the gate's DHCP and TCP work as before, twelve boots: 12 pass, 0 fail.

Deferred: Waves 2-5 (not started)

These are recorded so the scope is explicit, not because work has begun. Status stays In Progress until the Wave 5 gates pass, per the plan's own rule.

  • Wave 2 - IPv6, routing, firewall. Extension-header parsing with anti-abuse limits, NDP, ICMPv6, Router Advertisement, SLAAC, Duplicate Address Detection, DHCPv6, dual-stack AF_INET/AF_INET6 sockets with an explicit IPv4-mapped policy, a per-family route table with longest-prefix match and reachability, and a stateful default-deny firewall with audit events. On its own this is larger than the entire stack built so far.
  • Wave 3 - services, namespaces, switching. A ring-3 netd owning configuration, DHCP/DNS/echo reference daemons, network namespaces covering interfaces/routes/socket visibility/DNS/firewall, veth pairs and a software switch, plus namespace and capability identity attached to every socket. Blocked on a more capable ring-3 IPC layer than exists today.
  • Wave 4 - TLS runtime. The Mbed TLS adapter is hosted-only. A guest TLS client needs an OS entropy provider, a versioned trust store, TCP callbacks from ring 3 and handshake validation in QEMU. None of that exists; the current adapter is a dependency boundary and a version gate, nothing more.
  • Wave 5 - performance and release gates. Bounded buffer pools with backpressure, multiqueue virtio-net with IRQ affinity, rate limiting and per-namespace quotas, repeatable benchmarks, continuous protocol fuzzing, and a 24h soak. These are largely process gates rather than code.

Next checkpoint

  • Wire the policy into the x86_64 virtio/socket control path, add rule audit events, then add repeatable QEMU network integration tests, malformed-packet coverage and TCP lifecycle regression tests.

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.

Networking Stack (as of 2026-09-29)

In Progress (IPv4 runtime plus portable route/firewall data-path enforcement verified). M-005: a TCP RST is believed only when it could have come from the peer - it closed any connection matching address and ports, and a listening socket too; now RFC 5961 3.2 (exact rcv_nxt resets, in-window gets a challenge ACK, outside is dropped; SYN_SENT needs the RST to acknowledge our SYN; LISTEN ignores it). Red host test first, 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

Pipes decided end-of-file outside their lock

Status: FIXED (2026-09-11). An external review reported the write half; the read half, which it mentioned only as "ideally", was the more serious.

The write half

hw_pipe_write tested pp->readers == 0 before taking g_pipe_lock, while hw_pipe_release decrements readers under it. A writer could read one reader, the last reader close on another core, and the writer then enqueue anyway: write returned the full length with no SIGPIPE and no EPIPE, for bytes nobody could read.

The read half, which was worse

hw_pipe_read found the buffer empty under the lock, released it, and only then tested writers == 0 - unlocked - to decide end of file. A writer could enqueue and close in between, and the reader returned end of file with those bytes still in the buffer: lost, silently, in the shape of an ordinary pipeline such as the boot script's ls /EFI/BOOT | wc -l.

The fix

Each decision is made in the critical section that holds the data it depends on, with no new lock. The reader computes end of file - nothing copied, nothing faulted, no writers - before it unlocks. The writer tests readers right after taking the lock and before enqueuing, raising SIGPIPE after unlocking.

A missed wakeup between the reader's unlock and its sti; hlt is still possible and still harmless: hlt returns on the next timer tick, so it costs at most one tick of latency.

What was not done

No red test. Both windows are a few instructions between two cores, and a loop racing a short write against a close would pass nearly every time - a test that cannot fail proves nothing, and the case file would have to say so. The fix is structural and is argued from the code.

check.sh all green with the boot's pipelines (BUSYBOX_SH_OK, PIPE_OK). Twelve boots: 11 pass, 1 fail - the THREADS four-worker crash family (.boot-evidence/fail-20260911-162643-boot12.log, rip 0x405b72, panic), not a pipe.

Process Scheduler Progress

Status: In Progress (preemptive SMP runtime verified) Last review: 2026-08-28

Implemented

  • Real on-metal preemptive round-robin scheduler (kernel/arch/x86_64/arch_hw.c + isr.S): the timer IRQ (IRQ0) saves the interrupted task and restores the next by rewriting the live interrupt frame; sched_start launches the first task and ring3_resume returns to the kernel when the demo's bounded switch budget is reached. Verified in QEMU (GCC and Clang): two ring-3 user tasks (loaded from the embedded task ELF, distinct ids/stacks) interleave A/B under timer preemption (SCHED_OK). This is the hardware mechanism; it will be driven by the portable scheduler model below and gain per-process address spaces.
  • Per-CPU runqueues and core enqueue/dequeue primitives in kernel/sched/scheduler.c (portable model).
  • Preemption and wait telemetry counters (timeouts/wakes/preemptions).
  • Thread runtime tracking (RUNNABLE/RUNNING/BLOCKED) and wait transition accounting.
  • Affinity masks and thread nice-level controls with validation APIs.
  • Rebalance pass support with bounded migration budget.
  • Starvation tick and starvation-boost utilities for runnable threads.
  • QoS summary metrics (rebalance passes/moves, affinity misses, boosts).
  • CPU load snapshot API (vibeos_sched_load_snapshot) for deterministic benchmark/telemetry hooks.
  • Local APIC and AP startup support bring additional CPUs online; each core enters the runtime scheduling path in QEMU.
  • Runtime task lifecycle includes per-task kernel stacks, fork, execve, blocking waitpid, exit handling and address-space reclamation.
  • fork is copy-on-write. The child gets the parent's mappings rather than copies of its pages, which matters here specifically because a shell forks for every external command and the execve immediately after discards the copy - two megabytes of it for a program the size of BusyBox.
  • Per-task signal state. Pending and blocked masks, per-signal handler, flags and restorer, all inherited across fork except the pending set, which is not. Raising a signal against a task blocked in read() moves it back to READY so it can notice; delivery itself happens on the way back to ring 3.
  • Exit status carries a signal. A task killed by a signal records it, and waitpid encodes the exit code in the high byte and the signal in the low seven bits. 128 + sig is what a shell prints, not what the kernel stores.
  • Descriptors are part of task lifecycle: inherited on fork with pipe endpoint counts adjusted, and released on exit, so a pipeline's reader sees end-of-file when the last writer dies.
  • The portable process table now tracks process-group ID, session ID and session leader. Host APIs validate same-session group assignment and leader-only session creation (vibeos_proc_set_process_group, vibeos_proc_create_session, vibeos_proc_get_session).
  • The x86_64 console now broadcasts SIGINT to every live member of the foreground process group, with a bounded newest-task fallback during early boot. Runtime task-group reassignment and stopped-job semantics are still pending.
  • The x86_64 Linux personality now exposes setpgid, getpgrp, setsid and getsid with same-session and group-leader checks. The APIs are syntax-checked in the freestanding build; QEMU syscall/job-control coverage is still pending.
  • SIGSTOP now marks a task stopped and unschedulable, while SIGCONT clears that state and wakes it. The transition is implemented in the x86_64 signal path; user-visible stop/continue regression coverage still requires the QEMU gate.
  • The shell now places external foreground jobs in their own process group and uses TIOCSPGRP to transfer terminal ownership while waiting. Kernel-side session validation is implemented; interactive QEMU coverage and background jobs remain pending.
  • kill(0, sig) and kill(-pgid, sig) now address the caller's group or an explicit process group, restricted to the caller's session. This is syntax-checked but still lacks an interactive QEMU regression.
  • The portable lifecycle reparents children of a terminating process to PID 1, or clears the parent if PID 1 itself exits; handle and thread cleanup remains part of the same termination transaction.

Pending

  • Process groups and sessions. Ctrl-C currently raises SIGINT against the newest live user task because there is no job to target.
  • Hardware-task propagation of process groups/sessions and foreground terminal signal targeting.
  • Job control. There is no stopped task state: SIGSTOP cannot be caught or blocked, but its default action ends the task rather than suspending it, and SIGCONT is ignored.
  • Threads. clone with CLONE_VM or CLONE_THREAD returns -ENOSYS, and gettid reports the pid because there is exactly one thread per process.
  • Topology/NUMA-aware placement strategy.
  • Better priority/wake fairness policy under heavy contention.
  • Runqueue lock/atomic model definition for true parallel execution path.
  • Remove the current bounded demonstration scheduler assumptions and bind all runtime scheduling decisions to the portable runqueue policy.

Next checkpoint

  • Define the runqueue locking/atomic contract for concurrent AP scheduling, then add contention and fairness regression coverage.

Review findings tracker

One place for every external-review finding and its state, so none is lost between sessions. Updated whenever a finding arrives, is verified, or closes.

Read the key first. Every finding here has one ID in one sequence, whatever review it came from: H- for high, M- for medium, numbered in the order they arrived. The closed reviews before the eighth keep their own files and numbers.

Status words: fixed (commit named, red test first unless the row says why not), in progress, verified-open (confirmed against the code, not yet fixed), false (checked and refuted - evidence linked).

Earlier reviews are closed and written up: the sixth (review_sixth_verified.md), the seventh (review_seventh_verified.md) and those before (external_reviews_verified.md).

Eighth review (2026-09-10 onward, arriving one finding at a time)

IDSevFindingStatusEvidence / next step
M-001Mbrk shrink never unmappedfixed 1dc5b67mm_brk_mmap_leaks.md
M-002Mmmap failure left pages mapped (both loops)fixed 1dc5b67 + C5second loop found later; same file
H-003Hfutex validates a user pointer, then reads it after a spinning lockfixed (commit "core: a user copy that faults returns an error instead of panicking")assembly copy with a recovery point in the trap handler; futex read and the exit join-word write use it. uaccess_recovery_open.md
H-004Htwo region-list heads into one pool across threadsfixed ffa8463core_c5_process_state.md
H-005Hexit_group ended only the calling threadfixed ffa8463same file
M-003 (a)Msignal dispositions copied per threadfixed ffa8463same file
M-003 (b)Mpipe write tests readers outside g_pipe_lock; read half worsefixed (commit "core: a pipe decides end of file under the lock that holds its data")both decisions moved into the critical section with the data they depend on. No red test: the windows are a few instructions between two cores. pipe_eof_race_open.md
H-006Hexec from a thread left siblings running and the wrong idfixed 8a3903cTHREADS_C5_EXEC, red first
M-004Mtkill/tgkill could not reach non-leader threadsfixed d6d6b1breported again later; already closed
H-007Hpid resolved to a slot index, used unlocked (ABA); lookups matched slots being builtfixed da50ce6no red test: the window does not reproduce in a boot; stated in the commit
M-005MTCP RST accepted without a sequence check; also closed listenersfixed b4a65adRFC 5961 3.2; host test red first
M-006Mmmap len + 0xFFF wraps to zero pages; munmap/mprotect aligned end wrapsfixed a40aab0red in the ABI self-test, split-verified
H-008HTCP ISN = clock (0x1000 + now_ms, listen 0x2000 + now_ms)fixed (commit "net: TCP initial sequence numbers come from a secret")RFC 6528 with SipHash over one stack secret; host test red first. The secret is weak under QEMU TCG: no RDRAND, so it comes from the TSC mix and the boot log says so. Real entropy is its own open item
H-009HDHCP: predictable xid; ACK not bound to the chosen server, offer or chaddr; renewals from anyonefixed (commit "net: a DHCP reply belongs to this client's transaction with its server")xid from the stack secret; server port, chaddr, server id; ACK only from the chosen server for the offered address, renewals for the address in use; a NAK from anyone used to drop the lease and now must come from the server. Red twice (as written, and with the xid check off). Not closable by a client: an on-segment attacker answering the broadcast DISCOVER first
M-007MDNS: predictable id, fixed source port 0xC353, no server/port/question checkfixed (commit "net: a DNS answer is believed only if it answers the query sent")server, port 53, response bit, id and the single question checked; id and port from the stack secret per query. Host test red first - the first version was red for the wrong reason (it captured an ARP request) and was corrected and re-proved red at the right step
H-010Hread() on a pipe, recv(), recvfrom() validate the buffer, block, then write it after wake-up; a sibling's munmap makes it a ring-0 fault -> panicfixed (commit "core: a user copy that faults returns an error instead of panicking")hw_pipe_read (write inside the blocking loop), hw_net_recv, hw_sys_recvfrom; also the console read (same shape, not in the report). Fixed with H-003, same commit: pipe read/write, console read, recv/recvfrom/send/sendto through the fault-tolerant copy (socket data via a kernel bounce buffer). Boot probe asserted by the gate; sabotage of the recovery branch turns it red
M-008Many ARP overwrites the cache entry, gateway includedfixed (commit "net: a known neighbour's hardware address changes only by a reply the stack asked for")an entry changes only through a reply to the request the stack sent; a request for this host may add, never change; stale entries are used and refreshed by a request. Host test red first. ARP has no authentication: racing the real reply to a request is still possible
M-009MexFAT contiguous file: first + index wraps in 32 bits to a valid cluster; first_cluster from the entry never range-checkedfixed (commit "fs: cluster, run and length arithmetic from a volume cannot wrap")exfat_nth_cluster (return first + index), parse takes rd32(stream + 20) as is, exfat_read_cluster checks only the wrapped value. Also, not in the report: read_at truncates (offset + done) / cluster_bytes to 32 bits, so a large declared size wraps the index even with a valid first cluster. Host exFAT tests exist, so the red test is deterministic
auditMexFAT, ext2, ISO9660 and NTFS read_at: offset + len > node->size after offset < sizefixed (commit "fs: cluster, run and length arithmetic from a volume cannot wrap")found by the H-011 audit. Reachable only with a size declared near 2^64 and an offset within 4 GiB of it; then len is not trimmed and the read runs past the declared end (in exFAT, straight into M-009's index truncation). Fix with M-009/M-010: len > size - offset
M-010MNTFS run-list: len_size/off_size up to 15 bytes, shifted << (8 * i) into 64-bit types - undefined behaviourfixed (commit "fs: cluster, run and length arithmetic from a volume cannot wrap")ntfs_next_run checks only that the bytes fit. More than reported: the sign extension -((int64_t)1 << (8 * off_size)) is already undefined at off_size == 8, the largest legal size; shifting a byte into the sign bit of int64_t is undefined too. The reviewer's second candidate is also real: vcn < seen + run.length overflows on a crafted length
H-011HNTFS off + attr_len > size wraps in 32 bits, so a huge resident $DATA value is accepted and read_at copies past rec[] - kernel stack contents to user spacefixed (commit "fs: an NTFS attribute length cannot open its record up")bounds are len > size - off after off <= size in ntfs_find_attr and ntfs_data_attr; host test red first. Audit of every parser done: the 8/16-bit record walks (ext2, ISO9660, NTFS index and run headers) cannot wrap; the four read paths below share a weaker form of the same pattern, recorded as its own row
H-012HFAT: a first cluster from a directory entry is never range-checked; fat_cluster_lba is 32-bit and nothing bounds a sector by the partition - reads of other partitions' sectorsfixed (commit "fs: a FAT cluster from the disk names a sector of its own partition") - worse than reported, and the write half fixed toofat_cluster_lba (data_lba + (cluster - 2) * spc, uint32) is checked nowhere; only fat_next_cluster checks max_clusters. The partition's total sectors is a local of the mount, never stored. Writes too: a parent directory's cluster comes from fat_resolve unchecked, fat_dir_sector turns it into a sector with fat_cluster_lba, and create, unlink and mkdir rewrite that sector with one 32-byte entry changed - a crafted subdirectory turns "create a file here" into a write to a sector of another partition. Data writes use freshly allocated clusters and are not affected. Fixed in vibeos_fat_cluster_sector (host-tested, red first): cluster range, 64-bit sector, partition bound; fat_cluster_lba returns 0 for a refused cluster and every read, list, directory-sector and write caller stops on it. Twelve boots 12/12
H-013Hext2: inode block pointers never checked against the volume; blocks_count not kept after mount, so a crafted pointer reads sectors past the partitionfixed (commit "fs: an ext2 block number names a block of its own volume")ext2_read_block computed part_lba + block * sectors with no bound; ext2_map_block takes direct and indirect pointers as they come. Also, not in the report: ext2_read_inode takes the inode table block from the group descriptor unchecked, so a lookup alone can read past the volume. Fixed in the same place: the mount keeps blocks_count and ext2_read_block refuses any block at or past it. Host test red first, with a volume declared smaller than its device - an image whose volume fills the device would fail at the device and pass for the wrong reason
H-014HTLB shootdown timeout returns and the caller proceeds, so a permission restriction "succeeds" while a target core may still hold the old translationalready fixed - the finding read stale code (41fd9af)the finding quotes the timeout body (store 0; tlb_timeouts++; hw_log(...)) and stops one line early: it is immediately followed by hw_panic("TLB shootdown timed out: a core still holds a translation the caller has already revoked"). There is one shootdown implementation (hw_tlb_shootdown, the g_be.shootdown hook), and on timeout it fail-stops - exactly the finding's recommended fix. An earlier review found the old "return and continue" behaviour; the comment there records the whole reasoning. No live defect
H-015HTLB quarantine overflow (>512 parked frames) falls back to an immediate vibeos_frame_put - the old racy release - so a frame can be recycled while another core holds a stale TLB entry; a single process can force it by munmapping >512 not-yet-quiescent pagesfixed (leak on overflow; dynamic quarantine is the follow-up)hw_tlbq_put: the fallback is labelled "status quo: the old, racy release". The boot gate asserts tlbq_overflow=0 so CI catches it, but that is not a runtime guarantee. Fix: on overflow do a synchronous shootdown for that frame's address space then put, or apply backpressure until a slot is quiescent - not the racy put
H-016Hrt_sigaction and rt_sigprocmask dereference the user pointer directly after hw_user_range_ok (old/act read+write); a sibling thread's munmap between check and access faults in ring 0fixed (298b68d)same class as H-003/H-010 but thread-race, no blocking. hw_sys_rt_sigaction (old_uptr writes, act reads), hw_sys_rt_sigprocmask (old_uptr write, set read). Fix: vibeos_uaccess_copy
H-017Hrt_sigaction stores the handler (act[0]) as sig_handler[sig] with no canonicality/user check; hw_signal_deliver puts it in frame->rip, and iretq to a non-canonical rip #GPs in ring 0 -> kernel panic, from ring 3 via rt_sigaction + killfixed (05fa793, red test)canonical rip stays canonical, so validate at install: hw_user_addr_ok(handler) (rejects non-canonical and wrong-window). A canonical-but-unmapped handler faults in ring 3, which is safe. Red test possible: non-canonical handler + signal panics today
H-018Hrt_sigreturn reads the sigframe with a direct deref after hw_user_range_ok, and restores rip/rsp from the user-controlled frame without a canonicality check -> iretq #GP in ring 0fixed (read 298b68d, rip 05fa793)cs/ss/rflags ARE normalised (no privilege escalation), so the live defect is the non-canonical rip/rsp -> iretq fault, plus the TOCTOU read. Fix: copy the frame via vibeos_uaccess_copy, validate restored.rip/rsp canonical+user, SIGSEGV otherwise
H-019Hhw_copy_user_string validates each byte then derefs it directly; a sibling munmap between the two faults in ring 0. Used by execve, open, unlink, mkdir, stat/readlinkfixed (298b68d)int hw_copy_user_string(uptr,dst,max): hw_user_range_ok(uptr+i,1,0) then dst[i]=*(uptr+i). Fix: fault-safe per-byte vibeos_uaccess_copy, keep hw_user_range_ok as the address-policy check
H-020Hreadv/writev validate the whole iovec array once, then deref v->len/v->base per element; a sibling munmap of the array page faults in ring 0fixed (298b68d)hw_sys_writev/hw_sys_readv. Fix: copy each hw_iovec_t via vibeos_uaccess_copy into a kernel struct before reading base/len
M-017Msignal 64 is accepted (sig >= VIBEOS_HW_NSIG where NSIG=65) but 1ull << sig with sig=64 is undefined; the pending/blocked bitmask is 64-bit keyed by signal number, so signal 64 has no representable bit and can stay pending undeliveredfixed (278a803)VIBEOS_HW_SIG_MAX 63u added; the five range checks that read sig >= VIBEOS_HW_NSIG now read sig > VIBEOS_HW_SIG_MAX, so a signal that has no representable bit is refused rather than accepted and lost. Red test: syscall(SYS_kill, getpid(), 64) expects EINVAL, gated in musl_signal.c (SIG_OK)
H-021Hclone() writes CLONE_PARENT_SETTID/CLONE_CHILD_SETTID through a user pointer after the range check; a sibling munmap faults in ring 0fixed (188ec0f)hw_sys_clone_thread ptid/ctid stores. Same validate-then-use class; fix with vibeos_uaccess_copy, dropped on fault (the thread is created either way)
H-022Hwaitpid() writes status_ptr directly after publishing the task FREE, releasing the lock and re-enabling interrupts; a sibling munmap between check and store faults in ring 0fixed (188ec0f)hw_sys_waitpid. The reap is already complete, so a faulted write is dropped and the pid still returned, as Linux does after EFAULT there
H-023Hsignal delivery builds the sigframe directly on the user stack after the range check (magic/blocked/frame and the return address); a sibling munmap faults in ring 0fixedhw_signal_deliver. Built in a kernel buffer and copied out via vibeos_uaccess_copy; on fault no partial frame, task takes SIGSEGV
H-024Harch_prctl(ARCH_GET_FS) writes t->fs_base to the user pointer after the range checkfixedhw_sys_arch_prctl. vibeos_uaccess_copy
H-025Hioctl TIOCGPGRP/TIOCSPGRP deref the user pointer directly after the check (write pgid, read pgid)fixedhw_sys_ioctl. vibeos_uaccess_copy both directions
H-026Hclock_gettime writes the timespec (ts[0], ts[1]) through a user pointer after the range check; a sibling munmap faults in ring 0fixed (with a proactive sweep of execve argv/poison, time, crash stack). Still direct: pipe2 fd write, hw_stat_wr (all stat paths) - needs a kernel-buffer stat + undo; follow-up
H-027Hsetsid() checks and assigns current->sid/pgid OUTSIDE g_sched_lock, then takes the lock only for the sibling-member updates; setpgid/getsid/kill read these under the lock, so a sibling races the transitionfixedhw_sys_setsid: the pgid==tgid check and both current-> assignments are now under g_sched_lock with the sibling loop, so check+modify is one linearizable step; returns current->tgid to avoid a post-unlock read of a mutable field. No red test (thread-race, H-007 precedent); full check green, 3 boots pass
H-028HTCP SYN_RECEIVED stores cs->parent = listener slot index; if the listener closes and the slot is reused, the final ACK delivers the child into the new socket backlog - no identity checkfixedEach socket gets a monotonic gen at sock_alloc (net->sock_gen_seq); the child records the listener's gen as parent_gen at SYN time, and the final-ACK backlog insert now requires the parent slot to still be used && type==TCP && state==LISTEN && gen==parent_gen, else the child is dropped. Red test test_inet_tcp_accept_aba (close listener, new listener reuses the slot, drive the final ACK; the stranger's backlog must stay empty): fails on the used-only code, passes with the fix. Full check green, 3 boots pass
H-029Hmounted filesystems are not bounded by their partition: storage_scan drops sector_count, so ISO9660/exFAT read image-declared offsets/clusters as device LBAs and can read a neighbour partitionfixed (exFAT+ISO, red test; FAT/ext2 authoritative-bound upgrade is a follow-up)root cause in storage.c (only first_lba kept). Fix: propagate sector_count, bound each read by [first_lba, first_lba+sector_count). NB my H-012/H-013 (FAT/ext2) bounded by the IMAGE-declared size, not the partition - not fully authoritative; upgrade them too (follow-up)
M-018Mnewfstatat truncates node.size (64-bit) to uint32_t, so st_size is wrong (mod 2^32) for files > 4 GiBfixed (no red: fstat via fd path still 32-bit, separate)hw_sys_newfstatat uint32_t size -> hw_write_stat. Keep it uint64_t. Deterministic test (a node > 4 GiB)
M-019MISO9660 directory sector count (length + 2047) / 2048 wraps in uint32 (length near UINT32_MAX -> sectors 0), so a directory reads as empty and its files vanishfixed (red test)iso_dir_find and iso_op_list (duplicated). Use ((uint64_t)length + 2047) / 2048, centralize, and bound by the volume
M-020Mblocking socket syscalls (connect/accept/recvfrom) hold a raw hw_fd_t * across the wait loop and re-read f->net_sock; a sibling thread closing+reusing the FD slot makes the call act on a different socket - slot-index ABA in the FD tablefixed (reopened 2026-09-24 and closed: recvfrom had been missed)hw_sock_stable(f, sock, gen) in linux_socket.c: capture socket index + the socket's gen before blocking, re-verify on every wake (fd still used, net_sock unchanged, socket gen unchanged), else EBADF. Counted at [NET] MUSTBEZERO sock_fd_aba (also sock_stale_parent, H-028) - the network module's C2 must-be-zero. Reopened by an external review: the fix reached connect, accept and the stream read (hw_net_recv) but not hw_sys_recvfrom, the datagram syscall, which still re-read f->net_sock on every pass of its wait while this row said fixed. It re-verifies now, and check-net-stable.py (in check.sh) makes the rule mechanical: every function in net.c that holds an hw_fd_t and calls hw_net_wait_tick() must call hw_sock_stable. net-sock-stable.txt: removing it from recvfrom or accept is red by name
M-022Mext2 directory size can wrap and hide the whole directory. inode_size() builds a 64-bit size from i_size+i_dir_acl; ext2_dir_find() then does nblocks=(size+block_size-1)/block_size, which overflows to 0 for a size near UINT64_MAX -> no blocks scanned, every lookup fails. Same in ext2_op_list(). Value comes straight from the image; distinct from M-019 (that was ISO9660)fixedkernel/fs/ext2.c: inode_size() ~202-209; ext2_dir_find() nblocks ~219; ext2_op_list() ~378-380. Fix: validate dir size vs blocks_count*block_size at inode read; compute blocks without overflowing sum (size/block_size + (size%block_size!=0)); don't treat i_dir_acl as high-32 unless the feature level allows
M-023Morphaned TCP child stays allocated forever when the listener is closed during the handshake. H-028's parent check (used/type/state/gen) correctly drops the child, but the else path never closes/frees it: it still sets ESTABLISHED and clears rto_deadline_ms, then just skips the backlog insert. Child was sock_alloc'd (owner_pid=0, no FD), and the reclaim timer only frees sockets with close_deadline_ms!=0 -> used=1 indefinitely; a later RST -> CLOSED but not freed. Repeatable -> socket table exhaustion. Distinct from H-028 (that was misdelivery)fixedkernel/net/inet.c: tcp_input() child->ESTABLISHED ~1602-1605, parent-fail branch ends with no cleanup ~1614-1619; sock_alloc() ~481-496; vibeos_inet_poll reclaim ~1848-1855; tcp_input_rst ~1464-1474. Fix: if parent fails validation, do NOT establish - close+free the child immediately (used=0, CLOSED, deadline 0); centralize tcp_abort_socket(). Add a red test (SYN->close listener->final ACK->slot freed, no table exhaustion)
H-030HTCP half-open connections not reclaimed after retry exhaustion (remote DoS). Each SYN to a listener creates a SYN_RECEIVED child via sock_alloc(); when retries hit TCP_MAX_RETRIES the timer only sets state=CLOSED, reset=1, rto_deadline_ms=0 - never used=0 nor close_deadline_ms. Slot stays occupied. A remote attacker sends SYNs without completing the handshake, repeats to exhaust VIBEOS_INET_MAX_SOCKETS, blocking new TCP. No FD or listener-close needed (child is kernel-owned until accept). Distinct from M-023fixedkernel/net/inet.c: child creation tcp_input() ~1564-1586; retry reclaim vibeos_inet_poll() ~1878-1900 (~1886-1890 sets CLOSED but doesn't free); sock_alloc reuses only when used==0 ~480-496; general reclaim needs close_deadline_ms!=0 ~1848-1855. Fix: on retry exhaustion abort+reclaim fully (used=0, CLOSED, timer 0, remove backlog refs); centralize tcp_abort_socket(). Test: MAX_SOCKETS incomplete SYNs, wait retry expiry, table returns
M-031Maccepted TCP socket never inherits process ownership, leaks at exit. The child from the listener has owner_pid=0 (sock_alloc zeroes it); accept() assigns it to the new FD but never calls vibeos_inet_socket_set_owner(). hw_task_exit() releases sockets by tgid via vibeos_inet_release_owner_sockets(), which misses this child (no owner); the FD cleanup only sets f->net_sock=-1, not closing the stack socket -> orphaned kernel resource until table exhaustion. Normal path: listener->accept->thread exit without close(). Distinct from M-021fixedkernel/arch/x86_64/linux_socket.c: hw_sys_accept() t->fds[nfd].net_sock=child ~195 without ownership; kernel/net/inet.c sock_alloc ~481-496; arch_hw.c hw_task_exit ~5200-5221. Fix: after allocating the FD in accept(), set child owner_pid=t->tgid under g_net_lock. Test: open listener, accept many, exit without close(), all TCP slots reclaimed, later socket() no ENOBUFS
M-032Mrecvfrom() consumes the datagram and accept() consumes the connection even if copying the peer address to user space fails. Both do the socket op first then hw_write_sockaddr(); the return of hw_write_sockaddr() is ignored, so an invalid addr_uptr loses the connection/datagram with no -EFAULT. Reachable by passing a bad pointer; no SMP race. Distinct from H-016/H-019/H-026 (here the caller ignores the copy failure and keeps the side effect)fixedkernel/arch/x86_64/linux_socket.c: hw_sys_accept() (void)hw_write_sockaddr ~204; hw_sys_recvfrom() ~417-419; hw_write_sockaddr returns -1 on bad ptr ~35-57. Fix: on copy failure propagate -EFAULT; for recvfrom prepare the datagram before removing it from the socket (or make semantics explicit); for accept handle the failure before consuming the child, or roll back the new FD/backlog
M-033Mopen() truncates file size to 32-bit so fstat() returns a wrong st_size. vibeos_fs_lookup() returns 64-bit node.size but open() casts it to uint32_t into hw_fd_t::size (which is uint32_t); fstat() then passes f->size to hw_write_stat(), so a file >4GiB reads st_size mod 2^32. Reachable by open()+fstat on a fs declaring a >4GiB file. Distinct from M-018 (that fixed the by-name newfstatat path; here it is lost earlier, in the FD state)fixedkernel/arch/x86_64/arch_hw_internal.h: hw_fd_t.size uint32_t ~143-146; arch_hw.c hw_sys_open size=(uint32_t)node.size ~6220-6225, f->size=size ~6231-6233; hw_sys_fstat passes f->size ~8413-8417. Fix: make hw_fd_t::size (and pos) uint64_t and propagate the full size; widen lseek/pos too or read/lseek still truncate. Test: node >4GiB, open()->fstat() returns exact size
M-034Mkernel entry point not validated against loaded segments. The ELF parser copies e_entry straight into entry_point (PE: image_base+entry_rva); uefi_validate_segment_layout() checks segment size/range/overlap but NOT that entry_point falls inside a loaded, executable PT_LOAD/section. uefi_boot_handoff() then jumps there after ExitBootServices. A crafted/corrupt image with valid segments but e_entry outside them jumps to an arbitrary/unmapped address -> immediate crash instead of a clean reject. Reachable by replacing the kernel ELF on the ESPfixedboot/bootloader_core.c: ELF entry ~477, PE entry ~574-575; boot/uefi_pe_loader.c validate ~13-48 (no entry check), propagate ~75; boot/uefi_boot_handoff.c call ~142-148. Fix: after parsing, require entry_point canonical and fully inside a loadable EXEC segment (PE: checked image_base+entry_rva); reject before AllocatePages/ExitBootServices
M-035Ma KERNEL task does not preempt a running NORMAL one before its quantum ends: hw_higher_class_runnable() only tested mine == IDLE, while the policy orders KERNEL > NORMAL > IDLE and says a higher class always prevailsfixed (verified against the code: the loop compared only for an IDLE current task)the rule moved into vibeos_sched_policy_should_preempt() in sched_policy.c - candidate class < current class, so a class added later needs no special case - and the arch function only builds the runnable mask and calls it. Host test in sched_policy_tests.c (KERNEL over NORMAL, NORMAL over IDLE, nothing over KERNEL, no preemption within a class). Found while verifying, and corrected in the same change: the reviewer says the adopted kernel task is a KERNEL-class task; it is not - hw_task_adopt_kernel never admits it to the policy, and no task anywhere is admitted as KERNEL, so today the fix has no observable effect in a boot (the defect is real in the code, latent in the system). My first version treated an unadmitted slot as "unknown, so preempt", which made the kernel task lose the core every tick and turned the boot gate red with quantum_not_spent - now it is NORMAL, as vibeos_sched_policy_class already says, with a test. The arch wiring itself is not host-testable (hw_higher_class_runnable is static in arch_hw.c); it is covered by the boot
M-036MPROT_EXEC was not enforced: no user leaf ever carried NX, so every user page - stack, heap, a PROT_READ mapping - was executable and W^X was a property of the API onlyfixed (verified, and wider than reported: the arch layer builds most user leaves itself - ELF segments, stack, brk, mmap - not only through vibeos_vmspace_leaf_flags)NX on every user leaf unless executable is granted: leaf_flags, vibeos_vmspace_protect (both directions), ELF segments (X from p_flags), stack, brk, mmap and mprotect (PROT_EXEC now passed through). Three places rebuilt an entry and would have dropped the bit silently, each fixed and tested: the clone for fork, both copy-on-write installs, and swap-out/in (NX rides in the not-present entry). EFER.NXE is enabled on every core in hw_enable_syscall (the AP trampoline sets only LME), with a CPUID check. Reported by pageinfo (VIBEOS_PAGE_NX) and asserted from ring 3 in the ABI self-test: RW page has NX, +EXEC clears it, -EXEC sets it again. Host tests: leaf/protect/clone/COW/sole-owner in vmspace_tests.c. Not covered: an actual instruction fetch from a data page raising #PF
M-037Mst_blocks = (size + 511) / 512 wraps for a size within 511 of 2^64, giving 0fixed (verified)vibeos_ceil_div_u64() in include/vibeos/ceildiv.h (n / u + (n % u != 0)), used by hw_write_stat. Host test at UINT64_MAX and UINT64_MAX-510, red with the old form. The other + 4095) / 4096 sums in arch_hw.c are boot-time sizes from the memory map, or lengths M-006 already bounds - not touched. Re-reported 2026-09-24 against arch_hw.c ~8400, where hw_write_stat was before the syscall handlers moved to kernel/abi/linux/fs.c; the fix and its test were in place. What was missing was the sabotage: core-ceildiv.txt puts (n + unit - 1) / unit back and test_ceil_div_no_wrap goes red
M-038Hpageinfo read *(uint64_t *)phys from a frame it did not own: a sibling's munmap frees it, another process is handed it, and first_word returns that process's bytes (cross-process disclosure)fixed (verified; High rather than Medium - it is a disclosure reachable from ring 3 with no flag)new vibeos_frame_try_get() (a reference only if the frame already has an owner - it will not resurrect a free one). pageinfo pins the frame, re-reads the PTE and proceeds only if it still names the same frame, and takes frame identity, owner count and first_word from that one pinned instant; if the page is gone it reports flags only. The output copy also moved to the fault-tolerant vibeos_uaccess_copy (the kernel wrote out_uptr directly). Host test for try_get, red if it resurrects a free frame. The race itself is not reproduced: the window is a few instructions between two cores
M-039Mntfs_index_scan read $INDEX_ROOT's value_off/value_len straight from the attribute header and used them unchecked to build root and bound ntfs_index_walk - the H-011 defect (ntfs_data_attr, $DATA) never applied to the directory path. rec is a stack array in every caller (ntfs_op_lookup, ntfs_op_list), so a crafted directory record reads past it: a stack disclosure or crash reachable from an ordinary lookup or listing on a mounted volume, not a privileged pathfixed (verified; external report)ntfs_index_scan in kernel/fs/ntfs.c, mirrors ntfs_data_attr's bound: attr_len recomputed from the attribute (find_attr's own check, not trusted secondhand), value_off required in [0x18, attr_len], value_len required <= attr_len - value_off. First proof was a genuinely out-of-bounds value_off (0xFFF0): AddressSanitizer aborted on stack-use-after-return in rd32, called from a frame that had already returned (ext2_map_block's) - real, but not deterministic (a 16-bit offset may or may not clear an 8 MiB host stack depending on call depth; the same input passed clean on a later run with the defect still in place). The gate is test_ntfs_index_root_wrap now: a second, well-formed index blob planted elsewhere in the same record (still in bounds) that a corrupted value_off points at instead of the real one - the unfixed driver lists the decoy's name, "HACKED", proving the cross-attribute read without needing UB to be caught in the act. cases/fs-ntfs-index-root.txt, red on that assertion. Green under a plain host build, gcc, clang, 3 boots and ASan
M-040Mhw_sys_write's small-file buffering loop dereferenced the user pointer directly (f->wbuf[f->wlen++] = (uint8_t)p[i2]) instead of the fault-safe vibeos_uaccess_copy every other buffered path in the same file uses - the dispatcher's row validates the range once, before the handler runs, but that check and this read are two instants (H-010's family): a sibling thread of the same process can munmap the buffer in between and the kernel takes the ring-0 fault instead of returning EFAULT. Reachable by any program with a second thread and no privilege beyond that; survived C4's move of the range check into the dispatcher, which changed where the range is validated but not that this read still trusted the validation to still holdfixed (verified; external report)hw_sys_write in kernel/abi/linux/fs.c: the loop copies through vibeos_uaccess_copy into the free space in f->wbuf, returning -EFAULT if the very first copy of the call faults (matching this file's convention elsewhere: total > 0 ? total : -VIBEOS_EFAULT, here 0 bytes are ever committed before the copy either way, so the two are the same). The race itself is not reproduced: it needs a second thread racing munmap against the syscall, which none of this project's existing test programs do; the recovery mechanism itself is proven by uaccess-recovery.txt, and the fix is the same pattern already applied and reproduced for H-010's other five sites
M-041Mswap-in never frees the slot it read from: vibeos_vmspace_swap_in (vmspace.c) brings a page's contents back and installs a present PTE, but the layer beneath it (swapmap.c) explicitly must not be depended on by vmspace.c (anon.c's own comment on the eviction side: it "is the layer below this and must not depend on it"), so nothing inside vmspace.c returns the slot - and nothing above it did either. hw_handle_cow_fault (arch_hw.c), the only real caller, discarded the slot number it read via vibeos_vmspace_swap_slot and never called vibeos_swap_free. Every page a process ever faulted back in permanently retired one swap slot; the eviction side (anon.c) already frees correctly on its own failure path, which is why only the bring-back side leakedfixed (verified; external report)hw_handle_cow_fault in kernel/arch/x86_64/arch_hw.c: the slot number vibeos_vmspace_swap_slot already returns is kept, and vibeos_swap_free(slot) is called once vibeos_vmspace_swap_in succeeds - the only place that knows both that the bring-back worked and which slot it emptied. Not independently reproduced: the leak needs sustained memory pressure to trigger repeated swap-out/swap-in over one boot, which no existing test drives, and the caller is architecture-only code with no host-test path; vibeos_swap_alloc/vibeos_swap_free's own round-trip is already covered by the boot-time swap self-test (io_bringup.c) and by vmspace_tests.c's host coverage of swap_out/swap_in themselves, neither of which exercised the missing call
M-042MCode scanning #124-126 (cpp/overflowing-snprintf, reported high): the C6 diagnostics torture (tests/kernel/diag_torture.c) built its expected lines with n += snprintf(buf + n, cap - n, ...). snprintf returns the length it would have written, so after one truncation n exceeds cap and the next cap - n wraps to a huge size_t - a write past the buffer. Test code, and every expected line fit its buffer with room to spare, so it never fired; nothing prevented the next longer dump from overwriting the torture's own stackfixed (verified; code scanning)One bounded app() helper (vsnprintf, format(printf) attribute kept) replaces all six such calls, including three the scanner had not flagged; a model text that does not fit fails the torture instead of being cut, because a cut model compares the wrong string. core_c6_diag.md
M-043MCode scanning #120-122 (cpp/stack-address-escape): vibeos_abi_linux_register keeps the caller's row pointer for the life of the registry, and the host ABI test handed it arrays on the stack, leaving the registry pointing into a dead frame until the next vibeos_abi_linux_reset. Nothing read it in that window; the first test to query the registry without resetting would have read whatever the stack held. The kernel's own caller (dispatch.c) passes static tablesfixed (verified; code scanning)The test's tables are static; abi.h states the contract (rows must outlive the registry). Not reproduced as a failure - there was no reader in the window - which is why the fix is the contract, written where the next caller reads it
M-044LCode scanning #123 (cpp/declaration-hides-variable, note): in ntfs_index_scan an inner attr_len ($INDEX_ALLOCATION's length) shadowed the outer one ($INDEX_ROOT's, added by M-039). Correct today; in a parser of an external format, two attributes' lengths under one name is how one bound gets checked against the otherfixed (code scanning)Inner variable renamed alloc_len, with the reason beside it
M-045LCode scanning #118 (cpp/loop-variable-changed): tcp_abort_socket (kernel/net/inet.c) shifted a listener's backlog with the search loop's own counter, correct only because a break followed. Sabotaging the removal (leave the child queued; do not close the gap) went NOT RED against the host suite, and the reason is structural: a child joins a backlog only on becoming ESTABLISHED, and the routine is only called for SYN_RECEIVED children (H-030) and orphans (M-023), neither queued - the loop is unreachable todayfixed (code scanning); unreachable code recordedSearch and shift use separate counters. The unreachability is written beside the loop, with the two sabotages that would become runnable once a caller aborts a queued child; no case file, because an empty one would make check-subsystem.py count net/inet as sabotage-covered
M-046LCode scanning #111, #112, #117, #119: an unused local and a comparison made dead by the line above it in sched_torture.c; irq_handler in kernel_tests.c, orphaned when kernel/core/interrupts.c was removed; an inner k in hw_sys_execve (kernel/abi/linux/proc.c) shadowing the function's own kfixed (code scanning)Removed, removed, removed, and the inner counter renamed ci (17 lines, every one only the rename)
M-047HNTFS reads had no volume bound at all: exFAT and ISO9660 were bounded by their partition in H-029, and vibeos_ntfs_t did not even have a field for it, so a run list pointing past the partition read the neighbouring partition's sectors through an ordinary open() and read() (the block cache bounds by the whole disk)fixed (verified; external report)ntfs_read_sectors, the one funnel every NTFS read goes through, is held to the tighter of two limits: the partition table's (part_sectors, set by storage.c as for exFAT/ISO) and the size the volume declares for itself (boot sector 0x28, plus the backup boot sector), which also bounds a volume mounted with no table. Checked as count > limit - sector, never as a sum. Red first: test_ntfs_read_bounded_by_volume (cluster 100 refused, cluster 50 still served, for each limit). fs-ntfs-bounds.txt cases 1-2
M-048HNTFS directory index walker: every bound was off + n > len with off read from the index header - a first-entry offset within 16 of 2^32 wraps the sum, passes the check, and the entry is read from four gigabytes past the node. The report placed it in the $INDEX_ALLOCATION branch as M-039's twin; verified, the defect is in ntfs_index_walk, which serves both the resident root and every INDX block - M-039 bounded the root's value, not the offsets inside itfixed (verified; external report, location refined)Every bound rewritten as n > len - off after off <= len; an impossible first offset is a malformed node (counted, ntfs_bad_metadata), not an empty one. test_ntfs_index_offset_wrap: red by crashing the suite - the only offsets that wrap point 4 GiB away, so there is no in-bounds discriminator on a 64-bit host (the M-039 lesson, applied: said rather than dressed up as an assertion). fs-ntfs-bounds.txt cases 3-4
M-049Hext2 readdir (ext2_op_list) copied name_len bytes with no bound at all - lookup checked the block - so a name_len larger than its record copied the next records and, at the end of a block, up to 254 bytes of the kernel stack array past it into an unprivileged readdir()fixed (verified; external report)Both paths bound the name by its own record (name_len <= rec_len - 8), which also stops lookup's comparison running into the next record. Red first with an in-bounds discriminator: an 8-character name in a 16-byte record with name_len 12 made the unfixed driver return the next record's inode bytes as a ninth character, deterministically. fs-ext2-names.txt
M-050Hnet.c socket address helpers (bind/connect/sendto read; accept/recvfrom write) dereferenced the raw user pointer. The dispatcher's range check and the access are two instants - in accept/recvfrom separated by a blocking wait - so a sibling's munmap made the kernel fault in ring 0 and panic (H-010's family). Also found, not in the report: netctl ops 0 and 3 wrote the user struct directly while holding g_net_lockfixed (verified; external report + sweep)Both helpers copy through vibeos_uaccess_copy via a local; accept's undo path, written for a failure that could not happen, now can run. netctl gathers under the lock and copies out after releasing it. The race is not reproduced (it needs a thread racing munmap against a blocking accept); the mechanism is gated instead - see M-052
M-051Hread() on a regular file handed the user's buffer straight to the filesystem, which copies into it with plain memcpy - the same race-to-panicfixed (verified; external report)Read through a one-page kernel buffer (512 bytes on the stack if no page is free) and copied out with vibeos_uaccess_copy. A page rather than a small chunk because the FAT reader walks the cluster chain from the start on every call - small chunks would make a large read quadratic - and a kernel stack is 8 KiB or less. Not reproduced; gated by M-052's check
M-052Hwrite() to stdout/stderr read the user's buffer directly - M-040 had fixed only the file branch - and did so under the console lock; the NUL detector in the same branch also read it directly. The report's deadlock half does not hold: the console lock is recursive per core, so the panic path on the faulting core re-takes it; the panic is real. The sweep found four more of the same shape: getdents64, uname, prlimit (and M-050's netctl). Eight sites in one reviewfixed (verified; external report + sweep)The console branch copies in 128-byte chunks inside the one critical section - safe, because a faulting copy resumes at its recovery point before the trap handler prints anything; the detector reads through copies too; getdents64/uname/prlimit build in the kernel and copy out once. New check, check-user-access.py, in check.sh: in kernel/abi/linux, every integer-to-pointer cast must be an argument of vibeos_uaccess_copy, hw_copy_user_string or the pipe module (which copies through that callback); two reasoned exceptions (pageinfo reads kernel frames). It reports 11 violations at the parent commit and 0 now; core-user-access.txt puts three of the shapes back and it goes red each time
M-053MGUI canary written with 8-byte stores at back + width*height*4: on a screen with an odd pixel count that is a 4-byte boundary. Found by CI (clang Debug, UBSan: "store to misaligned address") on the first push of C7 step 4; gcc's UBSan in the nightly passed the same codefixed (verified)The canary starts at the pixel count rounded up to even (VIBEOS_GUI_GUARD_PIXEL), the buffer size is one macro (VIBEOS_GUI_BACK_BYTES), and init refuses a buffer that is not 8-byte aligned, by name. The host test's screen is 201x151 so the odd case runs on every build. io-gui-ubsan.txt puts the old placement back and the CI sanitizer configuration (verify-host-sanitized.sh) reports the same error
M-054Lcheck-exec-layering.sh exited 0 when the function it guards was gone - the one failure path left after the others were fixed to exit 1, so an exit-code reader (sabotage.py's verifier) saw a passfixed (verified; external report)exit 1. core-exec-layering.txt case 2 renames the function: NOT RED against the old script, red against the fixed one
M-055L.f.sh printed and asserted a list of checks that left out task-identity and exec-layering - and user-access, net-stable and fuzz-rc, which came laterfixed (external report)All asserted; the comment above the loop already said "every check it prints, it asserts"
M-056Hvibeos_vmspace_swap_out had no final re-check (external report, one line). Reading the code found it wider: reclaim runs from the allocation path on any core and evicts from another process holding none of its locks, so (1) the owner exiting freed the page tables being walked and CASed into, (2) munmap freed a slot still being written, (3) a fault read a slot not yet written - the entry said "swapped" before the write. The anonymous tier's hand and counters were unlocked toofixed (host-verified; external report)Claims in the rmap keep the victim's page tables alive (teardown waits in forget_root, bounded by a named panic); the page stays present and read-only-marked (VIBEOS_PTE_SWAPOUT) while written, a store cancels, and one final exchange commits - fork, munmap, mprotect and compaction all make it fail. Honoured by the fault handler, hw_user_range_ok and every entry rebuild. First host test of anon.c; 11 new sabotage cases all red by name. No run on a machine has ever reclaimed an anonymous page - the soak included (anon_scanned=0 at 12,000 rounds) - so the machine half is proved by review and host tests only. See mm_swapout_protocol.md
M-057Hvibeos_cache_get handed out a page-cache frame with no reference and the cache lock already released; the loader mapped it and the exec reader copied from it. An eviction on another core in between frees the frame, and the mapping then takes its reference on a page already handed to somebody else - whose exit later frees itfixed (internal)vibeos_cache_get_ref takes a frame reference under the cache lock; all three users give it back after copying or after the mapping holds its own. Host test: a referenced page survives an eviction and goes on the last put; mm-cache.txt removes the reference and it goes red
M-058MThe clean reclaim tier never reported a frame freed. It told an eviction from an empty slot by looking at the entry - and cache_evict empties an entry it evicts, so a page just given back looked like a free slot: the loop stopped at its first successful eviction and counted it as nothing (freed_clean=0 in every log there is)fixed (internal)cache_evict says whether it freed a frame; empty slots are skipped with a bound. Found by the first test to ask reclaim for a page; mm-cache.txt puts the old judgement back and it goes red
M-059LThe free-page poison watch printed under the frame layer's lock, taking the console lock inside itfixed (internal)Records under the lock; the allocating core prints after leaving the layer. Arch code with no host test - reasoned from CLAUDE.md's earlier deadlock of the same shape, not demonstrated
M-060HUnder svc-press's allocation load (28 MiB, far above the watermarks) the machine stops: cores fault in ring 0 at garbage addresses while inside the timer interrupt on its IST. Cause: the loader handed over the memory map it took before loading the kernel, so the kernel image was in the frame pool, and the allocator reached the page holding the IDT and the timer's ISTfixed (internal)The loader refreshes the map after its last allocation (vibeos_bootloader_refresh_memory_map); the kernel reserves its own image regardless and prints in_free_map=, which the gate asserts is 0. Mechanism shown both ways with svc-press: both halves removed wedges at 112 blocks, the old signature; the reservation alone survives to 256 with the gate red on kernel_image_in_free_map. Cases boot-memory-map.txt (boot) and boot-memory-map-core.txt (host). Write-up in mm_reclaim.md
M-061MA munmap burst overflows the TLB quarantine's 512 slots, and an overflowing frame is leaked by design (H-015) - svc-press unmapping 64 MiB loses 2,486 frames, ~10 MiB, for the rest of the bootfixed (internal)A frame is parked only when another core has the address space loaded (loading_cr3/loaded_cr3, published around every CR3 write); single-threaded unmaps never park. svc-press: overflow 0, 8 frames lost. svc-press now runs every boot and is gated; the old munmap_never_deferred_a_frame became timing-dependent (2 boots in 10) and was replaced by a kernel self-test of both cases. mm_tlb_quarantine.md, cases mm-tlbq-holders.txt, svc-press.txt, services-press.txt
M-062LThe free-while-mapped watch walks page tables after the frame layer's lock is dropped, then reads the owner count: another core can take the frame, map it, unmap it and free it again in between, which reads as a frame freed while still mapped. svc-press exiting with 64 MiB beside svc-stress produced four such reports in one boot of three, every one owners_now=1 or 2fixed (internal)The frame layer counts hand-outs per frame (the unused lru_prev became handouts) and tells the watch the count at release; a frame handed out since is free_watch_torn, not a defect. Same reproduction: 7 torn, 0 reported. A real one (fork's reference given back) is still reported, with owners_now=0. Cases mm-frame-handouts.txt (host), mm-free-watch.txt (boot)
M-063HA swapped-out entry was invisible to every operation but the page fault: fork skipped it (the child had no page there), munmap and teardown skipped it (the slot leaked, and after a munmap the entry still named a slot swap would give to somebody else), mprotect refused it, and the syscall range check returned EFAULT for a buffer in swap. Page-in restored every page user-writable - a read-only mapping or a PROT_NONE guard came back as ordinary memory - and took a second reference, so each page brought back outlived its process (764 frames lost in a boot that brought 756 back)fixed (internal)Swap-out keeps write/user/COW in bits the CPU ignores while the entry is not present, and page-in restores them; page-in adopts the caller's reference; munmap and teardown release the slot through a new swap_release hook, fork brings the page back through swap_bring_in (retrying if reclaim takes it again), mprotect updates the saved bits, the range check accepts a swapped entry. test_swapped_entries_are_mappings + mm-swapped-entries.txt (7 cases; the one that went NOT RED found a hole in the test, now closed). Gate: swap_slots_leaked, fork_of_swapped_page_failed. A second race found under load: the bring-in freed the slot it had read before the page-in, and a sibling thread could bring the page in and reclaim send it out again in between - the old slot freed twice, the new one leaked (swap_double_free=6 in one run). The page-in now releases the slot it consumed itself, through swap_release; nobody outside frees one
M-064HSwap had never run on a real medium. The gate's boot disk is QEMU's vvfat - a host directory presented as FAT - and a write into the middle of an existing file does not read back: every slot past the second returned slot 0's or slot 1's contents. The one-slot boot round trip passed every bootfixed (internal)The gate boots a real FAT image built by make_esp_image.py (VIBEOS_SMOKE_ESP=image, now the default; vvfat still selectable). The kernel writes eight slots before reading any back and refuses an area that aliases, and every page-in is checked against a hash of what was written (SWAP_CHECK MUSTBEZERO read_mismatch). Cases mm-swap-medium.txt, mm-swap-check.txt
M-065HTLB shootdown: two defects the first reclaim load made ordinary. It chose targets by the current task's cr3, so a core tearing down an address space from the kernel's tables - holding nothing of it, waiting for a reclaim claim with interrupts off - was targeted by the swap-out holding that claim, and the machine stopped. And one machine-wide pending count was reset by every shootdown, so two at once wiped each other's count and one could return before its targets flushedfixed (internal)Targets are the cores whose CR3 holds the space (loading_cr3/loaded_cr3, M-061); each target is waited on through its own cr3_generation; a core spinning with interrupts off answers flush requests addressed to it (hw_tlb_service_flush, in spinlocks, the claim wait and the shootdown wait). The gate compares flushes seen with targets asked (tlb_flushed/tlb_targets): acknowledgements stopped meaning it when two requests became one flush. The deadlock and the wiped count are races with no deterministic case
M-066Lvirtio-net polls, but left INTx enabled as UEFI had it - on line 11, which virtio-blk routes and handles. A received frame raised a level-triggered interrupt that only the next transmit cleared, into a handler that acknowledges a different devicefixed (internal)INTx Disable set at probe; the line is printed. The third driver to meet bit 10, after AHCI and virtio-blk. Not shown to be the cause of M-067
M-067MWith the real boot image, boots wedged in the AHCI driver - the log disk. The decisive one: all four cores spinning in ahci_lock under vibeos_blk_write, interrupt counts not moving (no storm), and nobody running the code that held it. The lock was a bare test-and-set with interrupts left on, and its wait halted with them on, so the holder could be interrupted or taken off its core in the middle of a command; every core that logged then queued behind a holder that could not get a core backfixed (internal)The driver takes the registry's lock (vibeos_dev_lock), which masks interrupts while held, names its holder in a deadlock report and refuses a core that already holds it. Waits poll; the completion interrupt is still raised and counted after the unlock. Seen about one boot in twelve before, and in two of the first four boots on the image; none in 24 after. A race with no deterministic case. virtio-blk keeps its own lock with interrupts on - its waits run in syscall paths with interrupts already off, so they never halt there - and is the same shape if that ever changes
M-068MThe kernel announced "all user tasks retired" and sampled the frame accounting while svc-reclaim was still running - thrashing at the minimum with swap full, in a boot where the first version of the load overran what reclaim could return. The wait loop counts READY, RUNNING and BLOCKED as alive and the task's user flag is stable, so it must have been seen in some other state for an instant; not found by readingopen (internal)The gate's premise check now fails any boot in which a user task exits after that announcement (it had only checked the stress service's line), so the next occurrence is a named failure rather than a leak-shaped number. Evidence: .boot-evidence/recl-run1/sab-3.log, announcement at line 545, the task's exit at 706. Instrumented: the announcement is followed by [SCHED] RETIRED_SLOTS, the state of every user slot that is not free. Not seen in 48 boots since 2026-09-27: the boot it appeared in was the one where the load's first version ran as an init service and init, whose oneshot had finished, exited while it was still running - an orphan, an arrangement that no longer occurs since the load moved into the self-test script. Not seen in 24 thrash boots (clock second chance disabled) nor in 16 boots of the arranged teardown race; all 40 printed a RETIRED_SLOTS line with zombies only 2026-10-09: dormant. The nightly's Boot Smoke x5 ran 145 boots from 2026-09-11 to 2026-10-09 with one failure, net_sock_fd_aba (fixed in 4a216f1), and every CI push boots four more; the gate fails on this signature and it never appeared. Nothing found by reading in this pass either. Kept open, not closed: absence in boots is not a mechanism, and the instrumentation stays so the next occurrence arrives described
M-069MThe anonymous tier has no notion of recency: its clock takes any single-owner page, the one about to be touched as readily as one nobody will touch again. Under svc-reclaim, swap is full within one 256 KiB block of the first crossing of the low watermark (past_low=1, swap free 0), and the pages taken are faulted straight backfixed (internal)The clock reads the accessed bit now (vibeos_vmspace_clear_young): a page used since the hand last passed is spared once and its mark cleared - the second chance. On the reclaim load: the full 48 blocks past the low mark instead of 1, swap half used instead of full, about half the evictions, 91k pages spared (anon_young). test_anon_reclaim_spares_the_young, mm-anon-young.txt. Before: the accessed bit is what a clock is for; anon.c did not read it. svc-reclaim stays inside what reclaim can return (stops at three quarters of swap) and releases each block as it checks it, so the boot is not starved - but the policy is the thing to fix, and svc-reclaim's past_low and swap figures are how to see it improve
M-070MOne boot in 24 with the reclaim load: svc-reclaim - single-threaded, no fork - killed by a not-present read in a block it had not yet checked, with swap_double_free=1 and one slot still allocated at the end. The page's entry was neither present nor swapped, so the page was lost and a slot freed twice; the swap counters otherwise add up (1296 out = 1278 in + 18 dropped), swap_out_raced=0open (internal)Not explained by reading. Evidence .boot-evidence/recl-run4/repeat-fail-10-*. Next: make the swap map's double free say which slot and which caller, and the ring-3 kill say what the leaf entry held - both counts alone say nothing. Instrumented: a second release of a slot prints SWAP_DOUBLE_RELEASE with the slot, the operation doing it and the one that did it before; a ring-3 page-fault kill prints the leaf entry. Not seen in 48 boots since M-069 stopped the load thrashing - which narrows it without explaining it 2026-09-27: its boot had svc-press exit beside the load - a large teardown while reclaim ran, the arrangement M-073 turned out to be. That arrangement is now made on purpose (svc-reclaim's abandon phase; rmap_claim_waits went from 0 on every boot to 1 in 9 of 16) and 24 thrash boots were run as well: no SWAP_DOUBLE_RELEASE, no lost page, in 40. Seen again 2026-10-01, and for the first time with the instrumentation (docs/abi/ L1 step 8, 1 boot in 18; evidence .boot-evidence/l1-step8/repeat-fail-5-*): during the reclaim load, with swap 76% full, init - not svc-reclaim - was killed by a not-present read at 0x8000001094 whose leaf entry was 0x8000000000158404: a swapped-out entry naming slot 0x158. Then SWAP_DOUBLE_RELEASE slot=0x158 now_by=destroy before_by=swap-in pid=5. So this time the entry was a swapped one, and its slot had already been given back by a swap-in running as pid 5, a different process: two address spaces believed they owned slot 0x158, or one slot was handed out twice. The swap map is a bitmap with no count, so a slot has exactly one owner by construction, and fork brings a swapped page back before sharing it - which leaves a slot allocated twice, or an entry left behind by a swap-in that raced, as the things to instrument next: record on each allocation who took the slot, as each release already records who gave it back. The step that saw it raised the user stack from 4 pages to 64, so every process has sixty more anonymous pages for reclaim to take, most of them shared with a forked child; that may be why init was the victim. 2026-10-01, docs/abi/ L3 step 1: a second and a third capture, a race found beside it, and no explanation yet. Second (.boot-evidence/l3-step1/m070-second-*): slot=0x158 now_by=unmap before_by=unmap pid=0xb, and svc-reclaim killed on a swapped entry naming slot 0x14b, released again at its teardown. Third (m070-third-*): init again, the same address 0x8000001094, entry 0x800000000017a404, now_by=destroy before_by=swap-in pid=5. A correction to the reading above: pid= is the process running when the second release happened, not the first - so "a page-in in another process" was never established, and the conclusion drawn from it (two address spaces naming one slot) is a guess. What the three do establish: the reclaim load is running; the victim is killed on a valid swapped entry (0x404: swapped, user, read-only) for a page it owns, so its page-in failed; and by the time the address space is torn down the slot is already free, last given back by an operation named swap-in. A race was found while reading for this and fixed - fork mapped a frame read out of a read-only entry after a page-out had released it (clone_one now pins with vibeos_frame_try_get and re-reads the entry; compact_tests.c reproduces it through the fork hook; fork_frame_gone, fork_entry_moved) - but the third capture came with that fix in and both counters at zero, so it is not this. Rate: 3 in about 60 boots since L1 step 8 raised the user stack to 64 pages, against 0 in about 90 before; sixty cold pages per process is sixty more for reclaim to take, most of them read-only copy-on-write after a fork. Instrumented further: `SWAP_IN_FAILED why=no_frame
M-071Mmprotect held the address-space lock for the region list only and narrowed the page tables after releasing it, so a fork on a sibling thread could copy an entry mprotect was halfway through changing - left that way because a sibling spinning in the lock could not answer the shootdown IPI, which stopped being true with M-065. Reading it found the other half: the range check and the pass asked for a present entry, so mprotect on a page in swap was refused with EFAULTfixed (internal, 2026-09-27)The lock is held from the range check to the last entry; a swapped entry is mapped and has its saved permissions changed. svc-reclaim makes every block read-only before reading it back; mm-mprotect.txt (check back to present-only) goes red with reclaim_load_failed. The lock itself has no case - recorded as untested. core_c5_residue.md
M-072MDescriptors were copied per thread: a descriptor one thread opened did not exist in the others, and a close in one left the rest holding it. And each thread's exit released every socket of its process by thread-group id, so a worker that finished closed its process's connectionsfixed (internal, 2026-09-27)The table is in hw_procstate_t; fork and exec copy it, a thread shares it. Claims, close and dup2 under a per-process lock (open claims before the disk lookup); the last thread to leave closes the table and releases the sockets. THREADS_C5_FILES went red against the previous kernel and is gated; fd-shared.txt has two cases; a leave that would take the count below zero is files_double_leave, must-be-zero - the first version of the clone case went NOT RED without it. core_c5_residue.md
M-073Lrmap_missing_remove fired about once in fifty boots. An eviction that claimed a page before its process's teardown commits after forget_root has removed every holder of the root, and its remove finds nothing - correctly: the frame is released once and the teardown frees the slotfixed (internal, 2026-09-27)Named by the instrumentation on the first reoccurrence (caller vibeos_vmspace_swap_out, an address in svc-reclaim's blocks, in a boot where svc-reclaim exited early with its blocks mapped). forget_root marks the claims it waits on and such a remove is counted as removed_after_forget; test_eviction_during_teardown, and a sabotage in mm-rmap-claims.txt. core_c5_residue.md
M-074LThe mount table (kernel/fs/vfs.c) had no lock. Attach appends and detach moves the last entry into the hole, so a resolve beside either can read a half-moved entry and return the wrong mount for a path. Latent: every caller today runs during single-core bring-up - serialised by accident, the page cache's shape in CLAUDE.md. Flagged by check-subsystem's state_without_lockfixed (internal, 2026-09-28)The table takes its own lock, registered by the architecture before the first attach (vibeos_fs_set_lock, as pipes and frames do). A call with none registered is the must-be-zero mount_unlocked, so a lock configured by nobody fails the boot. test_mounttab checks every operation takes and releases it once; fs-mounttab-lock.txt (two cases, host) and fs-mounttab-lock-arch.txt (registration removed: mbz_mount_unlocked) go red
M-075LThe fork guard's statistics were plain ++, correct only because its one caller holds g_sched_lock - a second caller outside it would lose counts. The verdict itself has no state: limits written once at init, counts passed in by the caller. Flagged by check-subsystem's state_without_lockfixed (internal, 2026-09-28)Atomic adds, and a comment saying why the guard takes no lock. No case: a lost increment needs two cores inside the guard at once, which neither a host test nor a boot arranges - recorded as untested
M-076MThe boot gate asserted the loop-mounted filesystem images by name for ext2 and iso9660 only. NTFS and exFAT were mounted every boot and asserted by nobody, while io-filesystems.txt recorded three red results for them - both rows removed, and the NTFS index allocation never walked - that the gate could not produce. And CI installed neither ntfs-3g nor exfatprogs, so there the images did not exist at all and would have passed as "no image"fixed (internal, 2026-09-28)The gate lists all four. exFAT passes on "OK (no marker)": no tool can stage a file into its image without mounting it, so a mount is all it proves and the verdict says so. The boot workflows install ntfs-3g and exfatprogs. The three prose cases are machine cases now (io-filesystems-gate.txt, io-filesystems-ntfs.txt) and were run red
M-077Mscripts/dev/trace-linux-binary.sh, which scripts/dev/README.md calls the method behind every syscall in the kernel's Linux layer, reported "(nothing - this binary's syscall set is covered)" for every binary. It looked for the handlers as case LSYS_ in arch_hw.c, which C4 replaced with table rows, and its name extraction had stopped matching strace's output - so both lists were empty and the comparison always succeededfixed (internal, 2026-09-29)Found starting phase A0 of docs/abi/. It now extracts names the way corpus-measure.sh does, reads each syscall's state and phase from docs/abi/syscalls.md, and exits non-zero if strace's output yields no names at all - so an empty measurement is reported as a broken tool rather than as coverage. Checked on musl_hello (missing=0) and busybox cp -a (missing: chmod, chown, utimensat)
M-078Mfork cloned the parent's regions and page tables under the address-space lock, released it, and only then copied brk_cur and mmap_cur. A sibling thread's brk or mmap fits in that gap, so the child could hold the regions of one moment and the break of the next: its own next brk then maps over pages it already has, or gives back ones it never got. The cursor's load was atomic, which made each read safe and the pair no more one snapshot than beforefixed (team review, 2026-10-01)Both are copied before the release (kernel/abi/linux/proc.c). Host test t_fork_snapshot_and_groups: the fake kernel fires a hook as the lock is released (kf_on_mm_unlock) that moves the parent's break and cursor, and the child must have the earlier values. Cases abi-fork-snapshot.txt 1-2, red. Not tested on four cores: the window is a few instructions
M-079Msetpgid(pid, pgid) accepted any pid of the session as pgid, without asking whether that process leads a group. A process could be put in a group numbered after a thread, or after a process that is itself in another group - a group whose number names nobody in it, for getpgrp to report and kill(-pgid) to aim atfixed (team review, 2026-10-01; reported as a second M-078, renumbered)A group is joined only if the process the number names has pgid == pid; making the target its own leader is unchanged. EPERM, as Linux answers when no such group is in the session. Same host test; case abi-fork-snapshot.txt 3, red. Still true: a group whose leader has exited cannot be joined, because the lookup is by the leader's pid
M-080MA call on a descriptor - fchmod, fchown, futimens, fstatfs, every AT_EMPTY_PATH - walks the path its description remembers (linux_walk_fd). Open /a, rename it to /b, create a new /a: the call then changed or reported the new /a, the wrong object, through a descriptor that still looked valid. No race is needed. The gap was written down in L1 step 5 as "a description does not follow its file across a rename"; what was not said is that it can land on somebody else's filefixed 4c703c5 (team review, 2026-10-01)The walk's answer is compared with the node the description was opened on, and another node is ESTALE - a refusal, where Linux would act on the renamed file. Only as good as the filesystem's node numbers: tmpfs's carry a generation; FAT's are the directory slot, so a new file in the old one's slot is not told apart. Case abi-fd-identity.txt, red. To close: setattr and statfs by node instead of by path in the filesystem operations, so a descriptor's calls never walk Closed 2026-10-09: the filesystems answer getattr and setattr_node by node (tmpfs by inode and generation, FAT by directory slot), and fchmod, fchown, futimens, AT_EMPTY_PATH and fstat act on the descriptor's own file wherever it was renamed to, as Linux does; fstatfs asks only the description's mount. A filesystem without the question still walks and refuses with ESTALE. A file whose last name is gone is ENOENT where Linux would still act on it. FAT's node is its slot, so its limit is unchanged. Cases abi-fd-identity.txt (2), abi-fd-identity-stat.txt, fs-fat-node.txt (2), all red
M-081MThe page cache names a file by a small number, and the table of those numbers had thirty-two entries and never gave one back. A file with no number is read whole into the exec staging buffer - four megabytes when that fallback was written, 64 KiB now - so the thirty-third distinct program of a boot did not start if it was larger than that, and execve said not-found about a file that was on the disk. A boot runs about twenty; the twelfth LTP test of a run was the thirty-thirdfixed b1fc301 (internal, 2026-10-02, found by LTP)512 entries, and running out is logged (no file identity left for the page cache). Not closed: an identity is still never returned, because a live process's regions name theirs (backing_id) and nothing counts who does. To close: count the regions and images that name an identity, and reuse one nobody names Closed 2026-10-09: the least recently looked-up identity is reused once the table is full, never one looked up in the last sixteen (a load reads at most two before mapping them). Nothing reads pages through a region's backing_id - an image is mapped at load, holding its own frames - so a stale id in a region only affects merging. Reading the table found two more defects: it had no lock (Linux exec and boot spawns call it from different cores), and it was keyed by path with the node seen first, so a file replaced at its path was served from the old one's pages; the node is compared on every lookup now. Shown with a table of 8: the boot passes reusing 19; with reuse sabotaged it fails, 24 files uncached and SQLite, Lua and glibc not starting. Counted in [EXEC] file_ids_reused=, file_ids_replaced=
M-082Mlinux_stat_get and linux_walk_at_empty read the first byte of a path - to learn whether it is empty - with vibeos_uaccess_copy and no range check. A path is not a pointer a row declares, so nothing had judged it, and that copy is fault-tolerant, not permission-checked: ring 0 reads any page that is mapped. statx, newfstatat and every AT_EMPTY_PATH call therefore read one byte of memory the program may not - its own PROT_NONE pages, or the kernel's - and answered ENOENT if it was zero and EFAULT (from the checked walk that follows) if not: one bit a callfixed (internal, 2026-10-02, found by LTP's statx03)linux_user_ok before both reads. Host test t_ltp_l1; cases abi-ltp-l1.txt 1-2, red - after the fake kernel's own copy stopped refusing what ring 0 does not (it had hidden this from every host test). The boot's ABI self-test asks statx and newfstatat about a path in a PROT_NONE page. check-user-access.py did not see it; the other copies from user pointers in kernel/abi/ were read through and are covered by their rows
M-083MA process group is the process's, and pgid lives in each task: setpgid wrote the one task the pid named, so the threads of a process could be in different groups for getpgid and for a signal sent to a group. And joining a group asked whether a task had that number as its pid - which a thread's id satisfies and a group whose leader has exited does notfixed (team review, 2026-10-02; reported as a third M-078)setpgid writes every task of the thread group, as setsid already did, and a group exists while any process of the session is in it (this replaces M-079's test of the leader). Host test t_fork_snapshot_and_groups; cases abi-fork-snapshot.txt 3-5, red. The number is still stored per task rather than in the process state - moving it is the cleaner shape and touches every reader
M-084HThe scheduler's policy (kernel/sched/sched_policy.c: classes, nice-weighted fairness, affinity) was never initialised on the machine. The architecture admitted every task into it and asked it to pick on every tick; with no slots every admission was refused, the refusals were ignored, the picker returned nothing and the machine fell back to round robin - and the policy was never charged the virtual time it picks by, so initialising it alone would have picked the lowest slot for ever. Class preemption (M-035), idle slices and nice did nothing on the machine for a month, while host tests and the torture were greenfixed (found in docs/abi/ L2 step 4, 2026-10-03, because setpriority changed nothing). Initialised beside the run queue with a panic if it refuses, charged on every core's tick; a refused admission is sched_admit_refused, must be zero, and the gate asserts policy_charged moved (sched-policy-live.txt, both cases red)

H-015..H-020 and M-017 arrived 2026-09-14, one at a time, all verified against the code. Four of them (H-016, H-018-read, H-019, H-020) are the "dozens of validate-then-use sites" uaccess_recovery_open.md already flagged, now being enumerated: one fault-safe-copy pass fixes them. H-017/H-018-rip are one theme (a ring-3-controlled RIP reaching iretq); M-017 and H-015 stand alone.

Checked by the reviewer and not promoted, recorded so nobody re-reports them: the futex waiter slot is kept until its own waiter returns (the earlier ABA is closed); ARCH_SET_GS is refused because %gs holds per-CPU kernel state; and hw_sys_lseek truncates the offset to 32 bits (the finding's own second item, withheld by its author for want of a security chain - it stays here, unpromoted, with the other validate-then-use sites).

Ninth review (2026-10-07, of HEAD 9fb7555, the A3-L4 week)

Static reading in four areas: four high findings and thirteen medium.

IDSevFindingStatusEvidence / next step
H-031Hepoll_wait/pwait/pwait2: the row's OUT_VEC read maxevents at 64 bits and skipped the check over the cap, the handler read the low 32 - 0x1_0000_0001 wrote an event, with the program's own word, through an unjudged pointer into kernel memoryfixed fbd776ba row says its count is an int (OUT_VEC32, VIBEOS_PTR_LEN32) and the engine reads it as the handler does. t_epoll writes through a static standing for kernel memory; abi-ptr-len32.txt 2 cases, red. poll and the vector calls compare all 64 bits, as the engine does
H-032Hmincore stored its vector under the mm lock; a store into a stack page not yet grown faulted into hw_stack_grow, which found the lock busy (held by the same task) and retried for ever, interrupts offfixed bd2590echunk under the lock, store after. mm_busy records the holder's slot, and a stack fault under its own task's lock panics with the reason. The fake counts user copies that could fault made under the lock; must be zero over the whole Linux suite. abi-mm-uaccess.txt, red
H-033Hmprotect made a MAP_SHARED mapping of an O_RDONLY descriptor writable - on tmpfs and memfd, the file's own pagesfixed 4e6e699VIBEOS_PROT_NOWRITE on the region, kept across every change of protection, EACCES from mprotect. mm-nowrite.txt 3 cases, red
H-034Hnested epoll: ep_ready's 2.5 KiB snapshot recursed EP_DEPTH deep on an 8 KiB kernel stackfixed 7ace0b0four entries at a time, a static assertion bounds the recursion's snapshots to 1 KiB (removing it stops the build, checked by hand). The host cannot see a stack overflow; abi-epoll-stack.txt proves the small snapshot is walked
M-085Mks_fpu_save hands 512 bytes to the signal frame with the reserved and software bytes (416-511) never written: stale kernel stack to userfixed 806e9a9the area is zeroed before FXSAVE; SIGNAL.ELF checks bytes 416-463 of a frame are zero. fpu-frame-clear.txt, boot, red (signal_l2_checks_failed)
M-086MFAT writes are not bounded by the partition (fat_scan_mount: (void)sectors), so a crafted BPB writes outside itfixed 63d4292the mount keeps the partition's length and every sector the driver reads or writes is judged against it (fat_sector_ok, counted as fat_out_of_volume). fs-fat-review9.txt, red
M-087Mfat_write_at grows the chain in memory and returns ENOSPC without saving the entry: clusters lostfixed 4996643a failed write gives back the clusters it added (fat_chain_undo); fs-fat.txt re-anchored, red
M-088M(agent) FAT zeroes a gap left by truncate or a far pwrite synchronously, under the driver's global lockverified, kept by decisiontrue, and bounded by the gap's size. Releasing the lock between chunks lets a truncate run between them; a spin window is unsafe with other spinlocks held by callers. Revisit with a sleeping lock
M-089M(agent) /proc/<pid> cwd, exe, root, maps and fd links readable by any processfixed b8c2331the four Linux asks the ptrace-read question of are refused to a process that is not the owner or root (may_inspect). fs-proc-inspect.txt, red
M-090M(agent) execve of a set-id program changed euid but left suid and fsuidfixed f178c53exec sets saved and fs ids to the effective ones, as Linux does; SIGNAL.ELF reads them back through /proc. abi-exec-cred.txt, boot, red
M-091M(agent) RLIMIT_AS, MEMLOCK and STACK stored and never askedfixed a0fc9a6mmap, brk and mremap ask AS, mlock asks MEMLOCK, the stack fault asks STACK. mm-rlimits.txt 2 cases, red; mm-rlimit-stack-boot.txt, boot, red (process_calls_failed). Check, 6/6 boots, LTP inotify unchanged (7 pass, the 6 known broken)
M-092M(agent) global pools with no per-process quota: POSIX timers, record locks, inotify instances and watchesfixed d1d8b37, 90431f7, 71eb05aa process may hold at most a quarter of each table. sched-ptimer-share.txt, fs-flk-share.txt, abi-inotify-share.txt, red. The file-description pool is left global: it is Linux's file-max, answered with ENFILE, and NOFILE already bounds a process
M-093M(agent) the last release and a new inotify_init1 race over turning notification offfixed b80aa5cthe switch is made under g_in_lock. No red test: the window needs two cores at one instant
M-094M(agent) regular.c's shared offset read and written without a lockfixed 87d5c22one call at a time per description (pos_busy). fs-pos-lock.txt, red. Not covered: getdents' offset, O_APPEND across separate opens
M-095M(agent) futex timeout and utimensat overflow for an enormous tv_secfixed f15708eboth go through saturating conversions. abi-time-overflow.txt, red. 2026-10-18 (eleventh review): the review's utimensat line is not in the tree - utimensat goes through linux_ns_of - but utime still multiplied, the one call the fix missed; it goes through linux_ns_of too, case 3 red
M-096M(agent) VMA pool exhaustion leaves regions and page tables disagreeingfixed 464a238every insert is made before the call reports success and a failed remove says so (VIBEOS_VMA_REFUSED); mmap, brk, mremap and munmap answer ENOMEM. mm-vma-exhausted.txt, red
M-097M.f.sh cannot pass; the scanning checks report ok with nothing scanned; sarif-merge.py takes an empty run as clean; no workflow runs the check-*.py; mdBook without a checksum, semgrep on a moving tag, the signed release built with fewer dependencies than the tested onefixed be918cc, 5ece3ca, 6f5d8ba, 8fad219, 6352b13, 87935dca floor of 1200 casts turned check-user-access red; static-checks.sh goes red on a missing python. SAFE in check-user-access matched the line; since 202eea5 a cast must sit inside the copy's arguments, and a planted raw cast beside a copy went red. Afterwards: the corpus's CPU-time workload, flaky on a fast runner, bounded by the clock (c04136c); job and install timeouts after apt held a run six hours (f1f4e52)

Tenth review (2026-10-13)

IDSevFindingStatusEvidence / next step
M-098MexFAT: a directory whose FAT chain loops, with no end-of-directory entry in it, was scanned for ever - exfat_dir_scan walked each cluster again from the first through exfat_nth_cluster and stopped only at an end marker or a chain that endedfixed 1a6fda4the scan follows the chain one link at a time with Brent's cycle check, bounded by the volume's cluster count; exfat_nth_cluster refuses an index past it; a broken chain is EIO and exfat_bad_metadata, not ENOENT. test_exfat_cyclic_directory (a loop of two and one of twelve, past the block cache, with a read count); exfat-cycle.txt 3 cases, red (the one without either check hangs)
M-099Mrecvmsg: SCM_RIGHTS descriptors were installed before the control buffer was written, and a failed copy left them in the table under numbers nobody was toldfixed 8a05bb1the buffer is judged before anything is installed; a copy that fails after (a sibling's munmap) takes back each descriptor that still names what was put there (linux_fd_withdraw, by description and tenancy); the sender's name is written before the rights. t_unix: a bad buffer and one that faults between judgement and copy, the lowest free descriptor unchanged after both; scm-rights-out.txt, the withdrawal red, the judgement alone NOT RED and why

Eleventh review (2026-10-18)

The review's R10-1 and its M-101 are one finding, kept as M-101. Two claims were checked and are not findings: M-096's rollback is in the paths named, and utimensat goes through M-095's saturating conversion - the overflow it describes is in utime, which M-095's fix missed (below, under M-095).

IDSevFindingStatusEvidence / next step
M-100Mclone with CLONE_VM and without CLONE_THREAD: page tables shared, region lists and locks separatefixed (2026-10-21)The address space's bookkeeping - regions, mm lock, break, mapping cursor, MCL_FUTURE - is its own counted object (vibeos_mm_t, ps->mm); a CLONE_VM child without CLONE_THREAD shares its parent's (ks_mm_share) instead of copying the list, threads share it through the process state, fork and exec make a new one. t_clone_l6: what the child maps the parent can mprotect and does not map over, and the break is one. abi-fork-snapshot.txt red; THREADS.ELF's C6_VFORK runs on it at boot
M-101MF_SETFL and FIONBIO each rewrite f->flags whole: two at once on one description lose one update (R10-1 is the same finding)fixed (2026-10-18)vibeos_file_change_flags clears and sets in one compare-exchange, and both calls go through it. fs-file.txt case 5 red for the bits it keeps; the race itself is not a case - the host tests have no threads - and is written down as such there
M-102Mcopy_file_range's overlap test summed an offset and the length, so a huge length wrapped the end and a file was copied onto itselffixed (2026-10-18)each end is held at the largest offset instead of summed; the host test puts the source above the destination, where the wrapped end landed below it. abi-file-calls.txt case 8 red (NOT RED with the source at 0: the wrapped end still came out above it), and case 3's anchor moved with the line
M-103Mmadvise accepted MADV_POPULATE_READ and _WRITE and populated nothingfixed (2026-10-18)vibeos_vmspace_populate brings each page back from swap and, for a write, breaks a copy-on-write share as a store would; a region the access is refused in is EINVAL, a hole ENOMEM, a failure EFAULT - Linux 5.15's answers, probed on the host. abi-mem4.txt case 15 and mm-vmspace.txt's populate case red
M-104Mmlockall(MCL_FUTURE) was accepted and later mappings were not lockedfixed (2026-10-18)ps->mlock_future, cleared in linux_procstate_defaults (new, fork and exec all pass there); mmap (EAGAIN past RLIMIT_MEMLOCK), brk, mremap and the stack's growth lock their new pages; munlockall ends it. Registry row 151 DONE. abi-mem4.txt cases 16-18 red; inheritance across fork is not a case, the fake kernel memsets its process states

Twelfth review (2026-10-19, of HEAD d02273e)

"(agent)" marks a finding the review took from a sub-agent and did not read again itself.

IDSevFindingStatusEvidence / next step
H-035Hmq_timedsend wrote page pointers past a message header's 500-entry array: msgsize_max and msgsize_default took up to 16 MiB, a non-root queue is bounded only by them, and the send loop had no bound of its ownfixed (2026-10-19)Both tunables, and queue creation for root, stop at LINUX_MQ_MSGSIZE_CAP (500 pages, what a message holds); the send loop refuses an index past the array as well. mqueue-l7.txt case red
M-105MNtOpenFile opens with the kernel's authority: no linux_may as openat asks, no search permission on the walk - an existence oracle for any path, and a 0600 PE of another user can be mappedfixed (2026-10-19)walked with the caller's credentials (vibeos_path_walk_as) and asked vibeos_cred_may for read, and for execute when FILE_EXECUTE is asked - the questions openat puts, through the personality-neutral layer. nt-openfile.txt two cases red; the case "a file asked for writing" re-anchored
M-106MNtMapViewOfSection maps the section's file by path again, so a rename between NtCreateSection and the map maps a different file from the one opened and checkedfixed (2026-10-21)vibeos_file_still_at_path asks, at section creation and at every map, whether the path still names the handle's mount and node, and STATUS_FILE_RENAMED otherwise. Since W3 step 6 the image is then read by the description's node (vibeos_file_node, getattr and never a lookup by name) and the page cache's identity is given for that node (hw_read_file_node), so a rename between the look and the read changes nothing that is read. What the look still guards is FAT, where a node is a directory slot that an unlink and a create can hand to another file - FAT's own gap, written down in CLAUDE.md. nt-section.txt and fs-description-node.txt red
M-107Mhw_stack_grow's "fault again" return does not serve a pending TLB shootdown: the same shape b39c801 fixed in ks_mm_lockfixed (2026-10-19)hw_tlb_service_flush() before the "fault again" return, as ks_mm_lock's waiters do. Not a case: the arch code is not in the host tests, and the boot reaches this only with a sibling thread in a shootdown while a syscall copies out to an ungrown stack page - the mechanism is b39c801's, whose boot case went red
M-108MKernel stacks have no guard page; the canary (hw_kstack_check) sees an overflow after the damage and only if the base word is writtenfixed (2026-10-21)Kernel stacks live in a region of their own at 256 GiB, whose directory and tables are built with the kernel's before any address space exists and recognised as shared by teardown: each a window of four unmapped guard pages and 16 KiB mapped a page at a time, from 72 windows (tasks and parked stacks). A switch invalidates the incoming stack's pages, so a window reused for another stack is never reached through a stale translation. The double fault runs on an IST stack of its own; a page fault or double fault in a guard panics naming the task. Deviation from the decision: the page fault is not on an IST stack - its handler can wait and be switched away from, and a second fault on the same core would land on the stack the first still uses; the double fault catches the case the page fault cannot push. The canary stays as a second line. mm-kstack-guard-boot.txt: getppid recursing off its stack ends in "kernel stack overflow: a task ran into the guard page" (its first version was turned into a loop by gcc and wedged; the wedge report prints rsp since)
M-109Lmsgctl(IPC_INFO/MSG_INFO) copied linux_msginfo_t uninitialised: two padding bytes of kernel stack to the programfixed (2026-10-19)zeroed before it is filled, as shm.c's IPC_INFO is. The test fills the stack with 0xA5 first (dirty_stack): without it the host's stack was zero where the struct landed and the sabotage went NOT RED. ipc-msg-l7.txt case red
M-110L(agent) Global tables with no per-process quota: 256 NT objects, 512 futex waiters (futex_waitv up to 128 a wait), 64 pipes of up to 1 MiB each, g_undo[512]; M-092 covered timers, locks and inotifyconfirmed and fixed (2026-10-19)Each was a single process's to exhaust. M-092's rule, a quarter of the table per process: pipes (EMFILE; the machine's own exhaustion is ENFILE now), futex waiters counted by the entry's task's process (ENOMEM), SEM_UNDO entries (ENOSPC), NT objects by their creator (STATUS_QUOTA_EXCEEDED, mapped to ERROR_NOT_ENOUGH_QUOTA as Wine's error.c checks). ipc-pipe-share.txt, abi-futex-share.txt, ipc-sem-undo-share.txt, nt-object.txt, red
M-111L(agent) splice: peek, write and read in three holds of g_pipe_lock; room = capacity - pending can wrap after F_SETPIPE_SZ shrinks a pipeconfirmed and fixed (2026-10-19)The resize itself refuses to shrink below what a pipe holds, so the pipe is never inconsistent; but splice, tee and vmsplice read the size and the count in two holds of the lock, and a shrink between them made the difference wrap - a write then waited with bytes already looked at. Four sites now ask vibeos_pipe_room, one hold. Host test for room; the race is not a case (no threads on the host). The three holds of peek, write and read are by design: the far side's write can wait, and must not under the pipe lock
M-112Lmq_getsetattr rewrites f->flags whole: the sibling of M-101fixed (2026-10-19)through vibeos_file_change_flags, the old flags taken from the same step. Every write of a description's flags after its creation now goes through that function (grep: the only other is the allocation). Not a case, for M-101's reason: the host tests have no threads
M-113L(agent) shmget looks a key up twice without checking permissions againconfirmed and fixed (2026-10-19)The second look - after the file is made outside the lock, for a key another process created meanwhile - answered with the id, asking neither the permission question nor the size. Both looks go through shm_existing now. The permission question had no host test at all: added, and ipc-shm-l7.txt's new case is red with it (NOT RED before); the size case re-anchored
M-114L(agent, uncertain) linux_shm_attached counts regions at offset 0: a munmap of a segment's head may miscountconfirmed and fixed (2026-10-19)It did: after the head was unmapped the rest started further into the segment and was not counted, so a segment marked for removal was reaped while still mapped (shm_nattch 0 too). An attachment is now a run of regions each continuing the last - a hole in the middle counts twice, as Linux counts a split. Host test; ipc-shm-mm-l7.txt case red
M-115L(agent) NT mutant: owned and published before it is initialisedconfirmed and fixed (2026-10-19)nt_ob_create published the named slot and released the lock; nt_create_common then took it again to set the state, so for that moment a mutant created owned was a free one any process could open and take. The state is set inside nt_ob_create, under the lock that publishes it. The race itself is not a case (no threads on the host); "an existing object is reinitialised" moved to nt-object.txt with the code and is red
M-116L(agent) sched_target turns a pid into a slot with no sequence checkconfirmed and fixed (2026-10-19)The slot was found under the scheduler's lock and changed after it was let go: an exit and a fork between put another thread there, and the change landed on it (its own credentials were then checked, so no privilege was gained - the wrong thread was changed). sched_apply and sched_setaffinity ask, under the lock that applies the change, whether the slot still holds the thread id named, and say ESRCH otherwise. Not a case: the host tests cannot put an exit between the two
M-117L(internal) hw_task_by_pid counted a thread that had exited and not yet been released: a non-leader thread goes through ZOMBIE on its way to FREE with no reaper, and in that moment kill(pid, 0) answered 0 for a process whose leader its parent had already reaped. THREADS.ELF's C6_ABORT saw it once (a thread of it outlived the process), on a boot of W3 step 6 that touched nothing of the sortfixed (2026-10-22)The first fix (2026-10-21) skipped a zombie thread in hw_task_by_pid; C6_ABORT failed once more in 12 boots with it in, and pid_dead_thread_skipped stayed 0 - a thread is ZOMBIE only inside the critical section that releases it, so that window does not exist under the lock. The failed boot's log said what did: the second thread's exited line is printed long before its slot changes state, so kill found it RUNNING on its way out of the exit_group - after waitpid had reaped the leader, which it did for any zombie leader. Linux reports a leader only once its group is empty (delay_group_leader). linux_wait_child now passes over a zombie leader while another thread of it is alive, and exit tells the parent - wake and SIGCHLD - from whichever finishes last, the leader or its last thread (hw_notify_parent). The window is reached on every boot: exit_leader_before_threads and exit_parent_told_late on the [TASKS] line were 1 and 1 (once 2 and 2) in all of 12 boots, 12/12 clean. The fifty yields C6_ABORT gave the pid after the reap were what hid it to one boot in fifteen; they are gone, and with them gone, removing the wait's check goes red on the first boot (threads-leader-reap-boot.txt), and on the host (abi-processes.txt case 7, t_processes)
M-118L(internal) The corpus workload bb-ps saw its parent - the shell, blocked in wait4 for ps - as R where Linux and every other boot say S: corpus_answers_differ:bb-ps, once in about seventy boots on 2026-10-21, in the boots that closed W3openThe shell blocks in linux_wait_child and is made READY by a child's exit or a signal; what woke it while ps read /proc is not known. Nothing of the workload is W3's. Next: have /proc's stat say which wake made a waiter READY, rather than guess from one sample
M-124M(internal) A silent boot wedge: a core in ahci_cmd's wait for PxCI to clear, the other cores idle behind the disk, after svc-reclaim's RECLAIM_SWAP; 3 of 6 boots under host load about 6 on 2026-10-22, 0 of 6 for the same binary on a quiet host and for its parent. The wait was bounded by twenty million turns, and once the wait halts - interrupts on, after 1024 turns - each turn lasts until the next interrupt, a tick: the bound was two days, so a command QEMU never completed was never timed out, and the gate saw a silent machine at a misleading phase (busybox_cat)mitigated, cause open (2026-10-22)The wait is bounded by time now, five seconds of timer ticks; a command that runs out prints [AHCI] command timed out with CI, IS, TFD, SERR, CMD and the IRQ count, is counted, and reaches the gate as io_timeout instead of a wedge. ahci-timeout-boot.txt: a late write never seen to complete times out at ticks=0x1f5 with the guest still talking. Why QEMU leaves a command uncompleted under host load is not known; the next occurrence prints the port's state, which is where to start

Thirteenth review (2026-10-22, of HEAD 74df400)

IDSevFindingStatusEvidence / next step
M-119MTCP accepts ACKs up to two sequence numbers beyond buffered data even when no SYN/FIN is outstanding. tcp_ack_data clamps the byte count to tx_len but stores the unvalidated ACK in snd_una, which can move it past snd_nxt and break flight-size accounting / subsequent sendsfixed (2026-10-22, c8f79ba; GitHub #14): the bound is snd_nxt, host test test_inet_input_review, sabotage in net-l5-inet.txtkernel/net/inet.c:1947-1964, caller tcp_input:2131-2133. Issue #14. Reject ACKs beyond the sequence space actually sent; only account for SYN/FIN sequence numbers when outstanding. Add cases for snd_nxt+1 and snd_nxt+2
M-120MIPv4 input checks only the 13-bit fragment offset (0x1FFF) and misses the MF flag (0x2000). A first fragment with offset zero and MF set is passed to transport handlers without reassembly; TCP checksum validation then covers only the fragment payload and can accept the partial segmentfixed (2026-10-22, c8f79ba; GitHub #15): MF and the offset both refused, host test and sabotage as M-119kernel/net/inet.c:2228-2285, fragment check at ~2245. Issue #15. Drop packets with MF set as well as nonzero offset (while allowing DF); test a first fragment with MF set and a checksum matching the fragment bytes
M-121LDNS accepts the first A record in the answer section without validating its owner name or RR class, then caches that address under the outstanding query name. A response can echo the right question yet supply an unrelated A owner; the resolver accepts it after M-007's source/port/id/question checksfixed (2026-10-22, c8f79ba; GitHub #16): owner name and class IN checked, a CNAME chain followed; test_inet_dns_answer_reviewkernel/net/inet.c:1352-1395. Issue #16. Parse and validate answer owner/class; only accept the queried name or a validated CNAME chain. Add a response with the right question but unrelated A owner and assert it is not cached
M-122Lfallocate() reports success without reserving the requested range: mode 0 only extends visible size via truncate, and FALLOC_FL_KEEP_SIZE does nothing. Future writes can still fail for lack of spaceknown gap, documented (reviewed 2026-10-22). Not new: the comment above linux_sys_fallocate says the size is guaranteed and the space is not, and linux_syscalls.def row 285 is PARTIAL for exactly that. The remedy proposed below - EOPNOTSUPP for mode 0 - would break the programs that call fallocate to extend a file, which is what the size guarantee is for; reserving space needs a filesystem that can promise it. Lowered M to Lkernel/abi/linux/fs.c:1045-1082. Issue #17. Until the filesystem layer can reserve the range, reject both modes with EOPNOTSUPP rather than silently succeed; test both modes and, once supported, verify actual reservation and KEEP_SIZE semantics
M-123Ltimerfd_settime() accepts TFD_TIMER_CANCEL_ON_SET without TFD_TIMER_ABSTIME, although Linux rejects that invalid flag combination with EINVALwithdrawn (2026-10-22): not a defect. Linux accepts the combination - a probe on the host's kernel (5.15) answered 0 for timerfd_settime(fd, TFD_TIMER_CANCEL_ON_SET, ...) on CLOCK_REALTIME and on CLOCK_MONOTONIC; CANCEL_ON_SET only takes effect beside ABSTIME on a settable clock, and is otherwise ignored, as VibeOS ignores it. The fix proposed below would make VibeOS refuse what Linux acceptskernel/abi/linux/events.c:108-134. Issue #18. Reject CANCEL_ON_SET unless ABSTIME is also set; add an ABI test for the invalid combination
M-125Hvma_change_locked read cur->next before vma_try_merge, which can absorb that successor into cur and free it; the walk continued from the freed descriptor, and prev never advanced. Reached by any mprotect or NtProtectVirtualMemory spanning regions the change makes alike (review of 2026-10-26, read in the source)fixed (2026-10-26, 55f4cf0): the walk continues from the descriptor that survived; a vma host test protects five alternating regions into one, and mm-vma.txt puts the old order back and goes redkernel/mm/vma.c, vma_change_locked
H-036HNT section views had no write ceiling: a handle with SECTION_MAP_READ alone mapped a read-only view and NtProtectVirtualMemory then made it PAGE_READWRITE, storing into shared pages. H-033's class in the NT personality (review of 2026-10-26)fixed (2026-10-26, 8b45ad2): a shared view through a handle without SECTION_MAP_WRITE, or of a section that is not writable, carries VIBEOS_PROT_NOWRITE; nt_abi test, nt-section.txt case redkernel/abi/nt/section.c, map_data_view
M-126MA failed fork leaks a kernel-stack window, 72 in all, after which every fork is ENOMEM (review of 2026-10-26)already fixed (#141): every failure branch of linux_sys_fork after ks_alloc_kstack calls ks_free_kstack, and a thread's clone has no failure exit after the allocation. Checked in the source 2026-10-26kernel/abi/linux/proc.c
M-127LNo per-process quota on the 2048-descriptor VMA pool: one process can take every region (review of 2026-10-26, from its sub-agents)fixed (2026-10-26, 738faa7): an address space holds at most a quarter of the pool, as the futex and record-lock tables bound a process; past it an insert or split is refused and every caller answers ENOMEM. vma host test, mm-vma.txt case redkernel/mm/vma.c pool
M-128LPrivate futexes are keyed by process state, not by address space: two processes sharing memory through CLONE_VM without CLONE_THREAD do not meet on a private futex (review of 2026-10-26)fixed (2026-10-26, 071e54c): a private word is keyed by the address space (futex_space); linux_abi test with a CLONE_VM child, abi-futex-share.txt case redkernel/abi/linux/futex.c, linux_futex_key
M-129Lkill may pick a thread that has already exited to deliver a process signal (review of 2026-10-26, from its sub-agents)fixed (2026-10-26, df84bb0): confirmed - a leader that called pthread_exit is a zombie while its threads run, and kill raised the signal on it. A zombie is passed over now; linux_abi test, abi-host-handlers.txt case redkernel/abi/linux/sig.c, linux_process_thread
M-130LSO_REUSEPORT lets a socket of another user share, and so take, a bound port: Linux requires the same effective uid (review of 2026-10-26)fixed (2026-10-26, a0ffa7b): the stack keeps the effective uid that asked for SO_REUSEPORT and shares a port only between equal ones; kernel test, net-l5-inet.txt case redkernel/net/inet.c:625
H-037HNtQueryDirectoryFile read its mask through the UNICODE_STRING's buffer pointer unjudged: vibeos_uaccess_copy is fault-safe, not range-checked, so a kernel address filtered the listing - an oracle on kernel memory (v7 review, 2026-10-26)fixed (6128571): every copy in kernel/abi/nt goes through nt_copy_in/nt_copy_out, which judge the range; check-nt-user-copy.py (static-checks) fails any other; the row declares the maskkernel/abi/nt/dispatch.c, file.c
H-038HThe W4 paths that change a directory asked nothing: disposition and DELETE_ON_CLOSE (unlinked with the kernel's authority), rename (neither directory, replaced its target), NtDeleteFile (no sticky bit), FileBasicInformation (any file's times); new files were root's. M-105's class again (v7 review)fixed (088ab90): the add/remove/own questions live in cred.c, shared by Linux and NT; DELETE and WRITE_ATTRIBUTES judged at open, rename asks both directories; check-nt-fs-permission.py fails an NT function that mutates the filesystem unasked (c17f12a)kernel/sched/cred.c, kernel/abi/nt/file.c
H-039HNo NT file service asked what its handle was granted: nt_file_get read the descriptor table straight, so a handle for attributes read data, one opened to read truncated or set times, a duplicate with fewer rights had them all (v7 review)fixed (6128571): nt_file_get takes the rights a service needs and goes through nt_handle_ref; each service names themkernel/abi/nt/file.c
M-131Mv7 review's mediums in the NT file services: alignment gaps of directory entries carried the kernel's stack; a mask of stars was exponential; every NT process shared one quarter of the lock table (pid 0); FILE_CREATE followed a dangling link; a rename's name was not case-insensitive; position and end of file took values past 2^63; touching locks were joined so an exact unlock failed; walks ignored the process's rootfixed (29969ed, 088ab90, acf4c57, 7f042ea), each with a host test and a case that goes red; the root is latent until L8's chrootkernel/abi/nt/file.c, kernel/fs/filelock.c
M-132MA Linux rename of a file left its descriptions naming the old path, and the release honouring DELETE_ON_CLOSE removed by path - the newcomer at the old name went; directory descriptions never honoured the mark at all (v7 review, M-080's family)fixed (c3821c4): a file's descriptions follow a rename; the release removes only a name that still names its file, by rmdir for a directorykernel/abi/files/regular.c, kernel/abi/linux/names.c
M-133LThe perf sweep's five notes (v7 review): the task generation moved before the state it announces; a tmpfs truncate stopped short of a middle page a failed write left; an initrd's suffix ran past the 4 GiB cut; a FAT table write failing half-way kept the chain cursor; the timer drained up to sixteen TLB-quarantine batches with interrupts offfixed (db9e0fa, 71b92e9, 7f87e44, cd245b8, 61d8bae). Two without a case, written down why: the generation's window is between cores, and the FAT cursor cannot be reached through the VFSsee the commits

Fourteenth review (2026-10-22, GitHub issues #19-#78)

Filed as GitHub issues; #14-#18 are M-119..M-123 above. Every claim about what Linux does was probed on the host's 5.15 before it was acted on.

IssueFindingStatus
#19ext2 read narrowed a 64-bit block index to 32 bitsfixed 9d8395c - no host fixture holds a file that large: no failing test
#20PE32+ ImageBase + RVA wrapped past 2^64fixed 00e92bd - section end checked; boot-memory-map-core.txt red
#21ISO9660 extent + offset narrowed to a low sectorfixed 9d8395c - as #19, no failing test
#22, #28, #29, #32, #33, #34, #38, #39sched_setattr size 0; clock_nanosleep on the process CPU clock; sendmmsg vlen; unknown semop/msgsnd/msgrcv/shmat flags; sigaltstack SS_ONSTACK; rt_sigqueueinfo si_signowithdrawn - Linux 5.15 accepts each, probed on the host (abi_probe.c); closed on GitHub
#23O_APPEND serialised only per descriptionfixed c9511cb - a per-file write turn; needs two threads, not host-testable
#24getdents moved the offset without the position turnfixed 4ae5718
#25flock range: negative length applied before the start was judgedfixed f416f98 - host test + sabotage
#26sync_file_range called fsync and ignored its argumentsfixed a16fcaa - host test + sabotage
#27epoll ET marked seen on a failed copyfixed afa9b4c - only a sibling munmap reaches it
#30SEM_UNDO judged per operation, not per callfixed 1bb266b - host test + sabotage
#31msgrcv negated LONG_MINfixed b19f2e5 - host test + sabotage
#35mq_notify dropped when the process closes any queue descriptornot as filed - Linux 5.15 drops it on any close by the owner (probed); this kernel kept it across a dup's close, fixed the other way in 3af08a1
#36, #41kill: INT64_MIN negated; signal 0 to a group asked about the callerfixed c5c9a86
#37prlimit64 on another process wrote limits without its files_lockfixed 23fb9aa
#40setpgid moved any process of the sessionfixed fbad98b - did_exec in the identity; host test + sabotage
#42rt_sigaction / delivery read and wrote the disposition unlockedfixed bec9e93 - under files_lock
#43, #44, #45clone3: wrapping stack, pidfd leaked on a failed copy, pidfd made after the child ranfixed 5d910b2 - #43 host-tested; #44/#45 need a race
#46signalfd mask read plainly, stored atomicallyfixed 2f9428e
#47, #50, #56, #58, #61, #68socket options: timeout overflow, unlocked flag word, FORCE capped, SO_DEBUG unprivileged, connect to broadcast, SO_DONTROUTE ignoredfixed b452671 - each probed on 5.15 first; host tests + sabotage
#48, #49, #51netctl ping/lookup shared by callers; start failures ignoredfixed 6b272e8
#52, #53, #54, #55kill once per thread; to a blocking leader; tid probe; tgkill orderfixed e1c7ef6 - host tests + sabotage
#57, #59SO_REUSEPORT on AF_UNIX, SO_PASSCRED on AF_INETnot reproduced - Linux 5.15 accepts both (probed); later kernels restrict them. Left to decide
#60SO_REUSEPORT stored and never told to the stackfixed 134d773 - the same-user half is not done
#62, #63, #69ICMP checksum; ping reply sequence; DNS TC/RCODEfixed c8f79ba
#64DHCP renew/rebind sent as a client without a leasefixed 2407927
#65, #66UDP and TCP demultiplexed by port alonefixed d3c812b
#67DNS retry after ARP misswithdrawn by its author
#70int arguments read as 64 bitsfixed 032efcf - descriptors narrowed where they are looked up
#71sched_setaffinity accepts a mask under eight byteswithdrawn by its author: a one-byte mask suffices for eight CPUs
#72, #73, #76, #77wait4/waitid dropped an unwritable rusage; setpgid/getsid tested 64 bits for zero; mprotect took unknown bits; poll ignored RLIMIT_NOFILEfixed 324fcc5 - each probed on 5.15; mmap's unknown bits and select's nfds Linux accepts, and so does this
#74MADV_DONTNEED leaves a private file page as the program wrote itknown gap, open: a private file region keeps no file or offset - its pages are copied at mmap - so there is nothing to read back. Needs a file reference in the region, through fork, split and teardown
#75MLOCK_ONFAULT / MCL_ONFAULT lock eagerlyapproximation, documented: Linux mappings are populated at mmap here, so every page exists to lock; eager is more than ONFAULT asks, visible only to mincore over swapped pages
#78failed mremap growth under MCL_FUTURE keeps its regionnot a defect: vibeos_umem_unmap removes the region; t_mremap_lock_rollback shows it, and a sabotage doing what the issue describes goes red
#79-#121 (performance)scans on hot paths, busy waits, missing indexesfiled 2026-10-22/23; taken a few at a time, the rest open on GitHub
#92, #93, #120TLB quarantine scanned whole after placing and on empty ticks; full swap map scannedfixed 101d554
#98real-timer table walked every tick with none armedfixed 293890d - sabotage in sched-ptimer.txt
#102quarantine drain stopped at 32 framesfixed ffb06d9
#109, #116boot DHCP and keyboard waits counted in pausesfixed d5c2316 - bounded by ticks, the count kept as a backstop
#96, #95, #86, #108, #115, #106, #107, #119, #117, #105, #99, #100, #97, #110, #90, #132, #133, #138, #139, #134, #118, #94, #137performance fixes, each one commitfixed 2026-10-23..25 - 6/6 boots on each batch on a quiet host
#135, #141frame pool above the identity map; a failed fork kept its kernel stackfixed 1dbf951, 2026-10-25
#17, #75fallocate reserves nothing; ONFAULT locks eagerlyclosed as documented approximations
label perf-phase (30 issues)the rest of #74, #79-#131, #136, #140deferred to a performance phase after W4: each changes a shared mechanism (readiness wakeups, cache locking, ring buffers, the boot order) and none is a defect; to be taken with the benchmarks #85 asks for

Found internally while verifying the above

WhatStatusEvidence / next step
ILLEGAL from=running to=running by=hw_task_exit, the slot a core's idle task (cr3_by=create_idle) - one boot in six while validating H-031..H-034, and the same line in .boot-evidence/fail-20260911-143808-boot7.loginstrumented, open (2026-10-07, 2d30753)hw_task_exit falls back to cpu->idle_task when the picker finds nothing and sets it RUNNING; the idle task was RUNNING already. hw_schedule marks an idle it leaves READY, so the idle was RUNNING somewhere else or left so by another path. Hypothesis to test first: the run queue can hand one core's idle to another core's picker, which would put two cores on one idle stack. Log kept as wedge-serial-2.log of that run. 2d30753 re-reads the core after cli (a task migrated between picking cpu and disabling interrupts would fall back to another core's idle), counts it as exit_migrated, panics if the core's current task is not the dying one, and prints every core's current and idle when the successor is already RUNNING. Twelve boots clean with exit_migrated=0: the migration hypothesis is not confirmed. Read the new line on the next occurrence
hw_task_exit makes next current with interrupts possibly on; a timer there saves the dying task's kernel frame as next's contextfixed (commit "core: exit switches tasks with interrupts off")must-be-zero counter exit_switch_irq_on red at 4 per boot, 0 with cli; twelve boots 12/12. It was not the cause of the four-worker crash family, or not the only one: the same signature recurred after the fix (.boot-evidence/fail-20260911-154006-boot7.log, rip 0x405b72, free-page-poison panic). The window was real and stays closed; the family is open again. boot_repeatability.md
Linux gcc / Debug red on three runs running, each differently: a kernel #GP in vibeos_file_get from execve's linux_fds_copy (a table entry held a non-canonical pointer), mm_frames_leaked=9, a wedge; locally a kernel jump into .bss (g_bc) after an NT program exitedfixed (2026-10-18)The exec of a Windows program ran past the base of its 8 KiB kernel stack in the unoptimised build: -fstack-usage put hw_pe_map_images at 2,880 bytes (two vibeos_pe_image_t locals, grown by VIBEOS_ELF_MAX_SEGMENTS 15 -> 24 in W2), hw_proc_create 2,624 and linux_sys_execve 1,632 on one path, with the cache and the disk below them. Stacks have no guard page, so the overflow wrote into the next frame and every symptom was somewhere else. A canary at each kernel stack's base, checked every tick (hw_kstack_check), turned the Debug boot from three different failures into kernel stack overflow at /win/hello.exe's exec, 3 boots of 3 (.boot-evidence/kstack-overflow/); the image descriptions are static under the locks their paths already hold. Evidence of the CI run in .boot-evidence/ci-39033105778/
THREADS four-worker crash family (new thread faults in musl start, stack is the free-page poison)open - and the probe masks itwith probes in the kernel: 0 crashes in 70 boots; without: 2 in 36 (probe-20260911-174620-boot1, -boot35). The quiet probe logged nothing and still masked it; what it kept was a page walk and a user read in hw_task_load_cpu_state when a thread starts from its initial context - so the window is at a thread's first start. The gate now snapshots every core from the QEMU monitor the instant the poison marker appears - no guest-side work (commit "gate: snapshot every core at a THREADS thread-fault"). Read the snapshot on the next crash. boot_repeatability.md 2026-10-09: dormant. The nightly's Boot Smoke x5 ran 145 boots from 2026-09-11 to 2026-10-09 with one failure, net_sock_fd_aba (fixed in 4a216f1), and every CI push boots four more; the gate fails on this signature and it never appeared. Nothing found by reading in this pass either. Kept open, not closed: absence in boots is not a mechanism, and the instrumentation stays so the next occurrence arrives described
Boot gate hung 48 minutes on a guest that had panickedfixed (commit "gate: the serial pump returns while a guest is still printing")the serial pump looped until the socket was empty, so a guest printing without pause - faulting in its own panic path - kept it from returning, and the deadline and silence budget, checked outside it, never ran. drain_available returns after 0.25 s; its self-test hangs with the budget removed
rmap_mismatch=1 with rmap_audit_torn=0partly fixed, recurred - reopened (commit "mm: fork's audit counts the references the TLB quarantine holds")an unmap removes the holder at once and the owner only when the quarantine drains, so a still-mapped frame has one owner more than holders, with the owner count still. The audit now adds references the quarantine actually holds; any other disagreement still counts. Host test red first. But rmap_mismatch=1 with torn=0 recurred on a kernel that includes this fix (probe-20260911-200632-boot9), so the quarantine was a real partial cause and not the whole one. Open. Next: when the audit finds a mismatch, log the frame and its holders, owners and quarantined count, so the disagreement is described rather than only counted 2026-10-09: dormant. The nightly's Boot Smoke x5 ran 145 boots from 2026-09-11 to 2026-10-09 with one failure, net_sock_fd_aba (fixed in 4a216f1), and every CI push boots four more; the gate fails on this signature and it never appeared. Nothing found by reading in this pass either. Kept open, not closed: absence in boots is not a mechanism, and the instrumentation stays so the next occurrence arrives described
A boot wedges in the reclaim load with one core in the disk driver (mmio_read under adapt_submit, AHCI, or virtio_blk_rw_n) waiting for a swap write that does not complete, the others idleopen, seen only on a loaded host (2026-10-13)Seen on L6 step 4's first run with a game on the host (0/6), and on 2026-10-13 at 0/6, 1/6 and - for the parent commit 38cfe29, 6/6 the day before - 3/12. All of the 2026-10-13 runs had two orphaned vibeos_kernel_tests spinning on two of eight cores, left by a hung sabotage case (sabotage.py now kills the group); with them gone the same tree booted 5/6, the sixth still printing at the deadline. So the stall follows host load. Whether a guest disk write can wait without bound when the host is slow is still open: time each swap write in the driver and print the longest
sched_setaffinity's caller still off its allowed cores after a second; THREADS.ELF's C6_SCHED fails about one boot in eighteen (exit 11, three seconds)fixed (2026-10-19)an INFO line (code 63) names what each allowed core runs. On most boots: another user task, NORMAL or IDLE class - the caller is released at its slice's end and waits in that core's queue. Not a refusal; a latency of the picker on a busy boot. Next: log the caller's virtual time against the core's runnable set when it fires. 2026-10-19: two boots of six on the twelfth review's fixes (HEAD eb95f74, host loaded at 38%) with this signature - mask applied, thread not moved in 3 s - and suspected of M-116, which touched sched_setaffinity. Checked: the parent d02273e 6/6 and HEAD again 6/6, both on an idle host; M-116 adds an ESRCH that would fail the call, where the logs show the mask set. The known latency, seen at a higher rate under load. Fixed 2026-10-19, from the INFO line's own numbers: core 1 was running another NORMAL task with less virtual time than the pinned one, and the picker orders by virtual time across the machine - the pinned task waited behind it, and the core it was leaving kept it for the rest of its slice. A task moved to cores it is not on is now placed at the lowest virtual time of what those cores run and wait for (vibeos_sched_policy_place, as Linux places a migrated task; never raised, not applied when already on an allowed core), and a core drops a task whose affinity excludes it at the next tick. With three busy loops on the host: before, 1 C6_SCHED failure and 3 boots waiting over a second in 6; after, 0 and 0 (those boots then wedged in the known AHCI swap-write stall the load causes). Idle host 6/6. sched-l6-policy.txt case red
The capability/MAC engine (kernel/core/policy.c, kernel/core/security.c) is called only by vibeos_kmain's own init (vibeos_policy_init) and by kernel/proc/process.c's token get/set - never from the real syscall pathscheduled: docs/abi/ L9 (2026-10-09)Verified: greping vibeos_policy_|vibeos_security_ against kernel/abi/linux/*.c and kernel/arch/x86_64/*.c (the real Linux ABI, the only path that serves a program) returns nothing. This is the architectural fact already on record - vibeos-arch-reality notes networking/security "still host models" as of the portable-kernel bring-up, and CLAUDE.md's own "BOOT_OK is printed after userland has finished" section says vibeos_kmain and the portable subsystems it owns run after the arch layer has already done all its work, "which is also why it can be host-tested and yet have no runtime role." process.c/policy.c/security.c are that portable model: a capability/MAC state machine with host tests, never consulted by hw_sys_*. Wiring real enforcement into every live syscall is a security-critical feature, not a bug fix - the scope of a phase, not a line. Left open rather than rushed: recorded here so the gap is legible instead of silently believed closed, and picked up as its own phase when the plan reaches it. That phase is L9 in docs/abi/phases.md - capabilities on top of L2's credentials, seccomp, Landlock - which is where it now lives

Order of work

Incoming findings first - the user's stated priority - and the core plan is not dropped:

  1. H-011 is done, and its audit found the read-path row above - to be fixed with M-009 and M-010 as one filesystem-bounds change. 0b. H-012 is done.
  2. The network findings H-008, M-007 and H-009 are done, and so is the exit-window fix - which did not end the four-worker crash family; that family is open again.
  3. H-010 + H-003 are done - and with them C5's "real exception table" item.
  4. Every review finding is closed. What remains open is internal: the four-worker crash family.
  5. Core plan C5, remaining: a must-be-zero check for hw_procstate_t. (fork against its own threads was closed by the address-space lock; mprotect's half of it by M-071; descriptors per thread by M-072.)
  6. The two open tooling/defect rows above.

Seventh review: an archetype, checked against the tree anyway

A full audit with scores — Security 3.0/10, Syscalls "BROKEN", one CRITICAL allowing arbitrary kernel memory overwrite. Its author later confirmed, asked directly, that the file paths and line references were reconstructed from what experimental x86-64 kernels usually look like, not read from this repository.

That admission matched the measurement. Nine of the ten paths it cites do not exist here:

absent  kernel/sys/syscall.c          absent  kernel/fs/elf.c
absent  kernel/mm/cow.c               absent  kernel/sched/sched.c
absent  kernel/mm/vmm.c               absent  kernel/arch/x86_64/sched/context.S
absent  kernel/net/ipv4.c             absent  kernel/net/udp.c
absent  kernel/sync/spinlock.c        EXISTS  kernel/mm/pmm.c

A wrong location does not make a claim wrong — the sixth review was right about the mechanism with the line numbers pointing at the wrong function. So every finding was checked against the code that is actually here.

Verdict, finding by finding

claimverdictevidence
C-001syscalls dereference user pointers uncheckedfalsehw_user_range_ok has 44 call sites; hw_user_addr_ok decides the two-window address policy; hw_user_range_why names the refusal. check-chokepoints.py fails if any count moves in either direction
C-002COW refcount is neither atomic nor lockedfalse in mechanismowners is 16-bit; vibeos_frame_get/put take frame_lock() around the _locked variants. The double-decrement it describes is separately prevented by compare-exchange on the PTE — the store decides who owns the release
H-001no IPI shootdown on unmaptrue, and fixedit is the sixth review's finding, closed the same day by deferred reclamation. Its second half is false: fork does shoot down
H-002p_vaddr + p_memsz integer overflow in ELFfalsekernel/core/elf.c:242 — add_ok(vaddr, memsz, &mem_end) is a checked addition, and the next line is vaddr < min_allowed || mem_end > end_allowed. Literally the recommended fix, in the two lines it recommends
H-003spinlocks taken without disabling interruptsfalsehw_spin_lock begins pushfq; popq flags; cli. It is the irqsave variant by construction; there is no other variant
M-001FPU/SSE not saved across context switchtrue, and fixedit was true for the whole life of this kernel and was fixed four days before this review, in 5aa68ef
M-002IPv4/UDP trust declared lengthsfalseUDP: if (len < 8u) return; then if (dlen < 8u || dlen > len) return;. IPv4: if (hdr < IP_HDR || total < hdr || total > len) → drop. Both compare declared against actual

Two of seven were real. Both were already fixed, one of them earlier the same day because a different review found it.

The part that is worth more than the score

M-001 was a genuine, serious defect that no other review found, and this one named it: fxsave/fxrstor appeared zero times in the entire tree while the kernel contained 6,955 XMM instructions and had CR4.OSFXSR set. It explained sixteen-byte corruption appearing three boots in ten, and white pixels in argv that turned out to be COL_TITLETXT left in a register by the GUI painting from an interrupt.

So the reviewer's instincts about where this class of kernel breaks are good. Four of the seven archetypal concerns are exactly the ones this project has actually had at some point: the FPU state, the COW refcount race, the TLB shootdown, and the user-pointer boundary. Three of those four were closed before the review arrived; the fourth was closed by the review before it.

That is a real result, and it is the argument for keeping these reviews coming.

The part that would have cost, had it been believed

The scores rest on findings that are false here. "Syscalls BROKEN", "Security 3.0/10" and "assenza di isolamento Ring 3 → Ring 0" all follow from C-001, which is refuted by a guard with 44 call sites and a check that fails when the count changes. "Filesystem: STUB / RamFS, manca persistenza reale" describes a tree that has FAT, ext2, NTFS, exFAT and ISO9660 behind a registry, a block cache, a partition table writer and a journal. "Nessuna protezione contro malformed packets" is refuted by the two length checks quoted above.

A number attached to a false premise is more dangerous than the premise alone, because the number survives being quoted while the premise does not.

What to hand a reviewer next time

The honest brief, since the archetype was reasonable and simply aimed at the wrong tree:

  • Read kernel/arch/x86_64/arch_hw.c. It is 10,000 lines and it is the kernel. Every security defect this project has actually had was there.
  • kernel/core/ and kernel/proc/ look like the kernel and are not reached from ring 3. Four consecutive reviews have been lost in them; the files now say so in a banner, and the largest of them has been deleted.
  • The interesting question is never "does a check exist" but "is it on the path the hardware takes". check-reachable.py, check-chokepoints.py and docs/core/ exist to answer that, and their answers are in the repository.

Sixth review: all three findings are in a file that no longer exists

Three findings, titled "soli problemi verificati e raggiungibili" — only verified and reachable problems. All three name kernel/core/syscall.c, which was deleted earlier the same day in 4462174.

That is not a gotcha about timing. It is the fourth review in a row to land entirely in that file, and the reason the file was deleted.

findinglocationverdict
H-001THREAD_CREATE bypasses ownership on target_pidkernel/core/syscall.cfile deleted; shape cannot exist in the live path
M-001userspace reaches the global boot_eventkernel/core/syscall.cfile deleted; no live syscall touches it
M-002lazy init of the global waitset is not SMP-safekernel/core/syscall.cfile deleted; the identifiers no longer exist anywhere

Verified rather than asserted

The dispatcher was unreachable from ring 3 before it was deleted. At the parent commit, git grep vibeos_syscall_dispatch -- kernel/arch/ returns nothing. The arch layer never named it. Nothing a user process could do reached any of these three.

M-002 is gone outright. waitset_initialized and kernel_waitset have zero matches in the tree. The race was real in the code as written and there is no longer any code.

M-001 is gone outright. boot_event survives in exactly two places, both in kmain.c: vibeos_event_init at startup and one vibeos_event_signal during boot. No syscall reaches it. The user-facing vibeos_user_signal_boot_event was removed in the same commit.

H-001's shape cannot exist in the live kernel. vibeos_thread_create in kernel/proc/process.c now has no caller outside the host tests. The path a real program takes 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. A caller can only create a thread in itself, so there is nothing to check ownership of.

But the principle behind H-001 is right, and the live kernel does implement it

"Check ownership before acting on somebody else's pid" is a real requirement, and the live kernel does have syscalls that take a target pid. They go through one function:

hw_signal_permitted  — 1 definition, 4 call sites (kill, tgkill, tkill, sig 0)

check-chokepoints.py declares that count and fails if it moves in either direction — a fifth caller that copied the guard, or a fourth that quietly stopped checking. So the property this finding is really about is enforced and watched, in the layer that runs.

What is worth taking from this review

Nothing to fix, and one thing to notice.

Four consecutive reviews have concentrated on unreachable code, and the reason is structural rather than careless: kernel/core/ reads like the kernel. It has a syscall dispatcher, a security layer, a process table, a waitset. A reviewer given this tree will study it, and every hour they spend there is an hour not spent on arch_hw.c, which is where every security defect this project has actually had was found.

The banner added to those files after the third review helped whoever read the file top to bottom. Deleting the dispatcher helps more. What remains in that shape is kernel/proc/process.c — 1142 lines with a process table, thread creation and a capability model that nothing on the machine consults. It is C5's subject and it is deliberately still here, which means a seventh review can still land in it.

That is now an argument for taking C5 sooner rather than later, and it is the first argument for the ordering that came from outside the project.

Tasks and Scheduling Progress

Status: In Progress — phases S-P0 to S-P2 of docs/sched/ are done. Counters and a console view exist, a transition table decides what a slot may become, every task reference carries its tenancy, and the run queue is a tested data structure. S-P3, which repairs the defects, is next. Last review: 2026-08-31

Why this is being rewritten

Everything about a task lived in kernel/arch/x86_64/arch_hw.c — 8312 lines, 267 static functions, with the scheduler, syscalls, program loading, pipes, signals, the network glue, the interrupt controllers and the console all in one space where any function can reach any state.

The defects that produced share one shape: a task's lifetime was managed by several pieces of code that each knew part of the truth. The silent wedge, the reaper writing to a published slot, execve not asking whether siblings shared an address space, a recycled slot keeping a stale cr3. Each was fixed where it was found; none could have been prevented, because nothing said what a slot's states were or who could change them.

What exists now

S-P0 — counters and the view

tasks on the kernel console prints every slot: generation, state, pid, tgid, ppid, cr3, whether it is user or kernel, thread or process, on a CPU or not, and the four provenance fields — cr3_set_by, ready_by, aspace_killed_by and who last changed its state. Three of those existed already, each added after a defect had cost a session.

Alongside it, counters for what the subsystem has done, and four that must be zero and are asserted by the boot gate: an illegal state change, a write to a slot already published as reusable, a stale reference used as if it still named its task, and a task about to run on tables somebody else freed. Each names a defect this kernel has actually produced.

The split is the point. Deciding what to say about a task is portable and lives in kernel/sched/view.c; only reading the machine's table is not, and the architecture answers one function. That is the shape the rest of the rewrite follows.

S-P1 — the transition table

kernel/sched/task.c owns what a slot may become. The transitions that are absent are the design: RUNNING to FREE is the silent wedge, and ZOMBIE to READY would let a reap race a wake and put a task back on a destroyed address space.

All twenty-six assignments to a task's state now go through one function, which asks the table and refuses rather than performs. Storage still lives in the architecture's field; what has moved is the decision.

The table was wrong on its first boot, and the machine said so. SETUP to RUNNING was missing — a core adopts the thread it is already executing, and an idle task is created already on its CPU. The refusal named the slot, both states and the function. With assignments scattered across nine places, that knowledge was written nowhere.

Tenancy is everywhere (decision T1). A bare slot number stays valid forever and names whoever holds the slot now, so a reference kept across a lock release silently becomes a reference to a different task. The generation is bumped when a slot is claimed, never when it is released: bumping on release leaves the freed slot already carrying the number its next occupant will have, so a reference taken beforehand would validate against the new tenant. That is a sabotage case, because it reads as equivalent and is not.

S-P2 — the run queue

hw_pick_next is a wrapper over kernel/sched/runq.c, where "two CPUs never pick the same slot" — a defect this kernel has had — is a unit test rather than a boot somebody hopes goes wrong.

The queue does not decide what may run; it asks through a callback, so the state machine stays the single source of truth about what READY means. A queue with its own opinion would be a second one.

A real defect was fixed in the move. The old scan started from the current task, which is fair while something is running and degenerates when the current task is idle: the search then begins at the same place every time and the high-numbered slots wait behind the low ones. There is a cursor per CPU now.

What this cost

A weak-symbol mistake broke the Windows CI. The view's defaults were weak definitions in one file with the real ones in another, and PE/COFF does not resolve that across objects — the Linux build linked and said nothing. Every weak stub that works in this kernel happens to sit in the same translation unit as its caller, which is why the pattern looked safe when it was copied. The view's data source is registered explicitly now, as the frame layer's lock is, and the trap is recorded in CLAUDE.md.

Next

S-P3, lifetime: exit, reap and exec in one layer, with the two ordering rules made structural rather than remembered, and the address-space ownership question asked in exactly one place. That is also what unblocks the loader rewrite, and through it memory-plan P4 step 3.

Scheduling policy — priorities, fairness, affinity, load balancing — is S-P5, after the per-task CPU accounting at S-P4 that nothing here has today. The first draft of the plan listed it as a one-line non-goal, which was wrong: a plan called "Tasks and Scheduling" that contains no scheduling is mis-scoped. The ordering argument stands, the omission did not.


S-P4 — Accounting (done, 2026-09-02)

The machine can now say where its time went. Nothing above this phase was possible without it: a policy decides which of several correct choices to make, and every way of deciding - priorities, weights, whether anybody is starving - is a statement about time already spent. Tuning a policy against numbers this kernel could not produce would have been tuning against a guess.

kernel/sched/account.c is arithmetic and nothing else: it makes no decision, holds no lock, and calls nothing, so it is host-tested exhaustively and a defect in it can never be a defect in scheduling.

Charged by tick, not by timestamp. Every tick goes to exactly one place - one task, or one core's idleness - which makes the check an identity rather than a tolerance:

[TASKS] CPUTIME charged=0x241 idle=0x3b0 seen=0x5f1 balanced=yes dropped=0x0
        cpu0_idle=0x10c cpu1_idle=0xfc cpu2_idle=0xae cpu3_idle=0xfa

577 + 944 = 1521, and the four per-core figures add to the idle total exactly. A tolerance is something you argue about when it drifts; an identity is something the boot gate asserts.

An idle task is a task, and must not read as work. Every core here has an idle task holding a real slot, so charging by slot alone would report a machine doing nothing as fully busy - and every ratio built on that would be wrong in the direction that hides a problem.

Every core charges its own tick, unlike the wall clock, which only core zero owns. The distinction is easy to get backwards: the clock counts wall time and this counts CPU time, and on four cores those differ by a factor of four.

Three defects in the wiring, two of them found by their own counters

  • The tick handler indexed the task table with a value it had not checked. It runs on every core on every interrupt, including before that table means anything.
  • Accounting was sized with g_cpu_online_count, which is 1 when the scheduler starts - the other cores come up afterwards. Three quarters of the ticks were refused.
  • The report read the totals, then walked the per-core counters, with ticks arriving in between. It printed a core with more idle time than the whole machine had. Two moments reported as one, which is the same mistake as a log line assembled from two writes.

The second is the interesting one. A refused tick keeps the identity intact - charged plus idle still equalled seen, because a refused tick was never seen - so the balance check could never have found it. It needed its own counter, and with that counter the sabotage is unambiguous: sizing the layer for one core again turns the boot red with cputime_ticks_dropped=1184.

That is the shape this project keeps meeting: a check that cannot fail is not a check. The balance assertion is real, and it would have watched this bug go past.

And then the balance assertion was wrong in the other direction

It asserted an exact identity, and turned a correct kernel red twice in twelve boots: charged=540 idle=874 seen=1413, one apart.

Two separate faults, and both are mine. The three totals were incremented non-atomically from four cores - excused in a comment saying a lost increment "costs one tick", which was wrong about the consequence: it costs the identity, and the identity is the whole promise. They are atomic now, which at a hundred ticks a second on four cores costs nothing measurable.

The second is the more interesting. No read order makes it exact. A tick increments the total and then one of the parts, so a core caught between those two adds leaves the parts one short; reading the parts first just moves the discrepancy to the other side. The reader is not atomic with respect to four writers and cannot be made so cheaply.

What is true is that at most one tick per core can be mid-update, so the two sides agree to within the number of cores - which is exactly the bound docs/sched/phases.md asked for, and which I tightened into an identity without noticing I was changing the requirement. The bound is a fact about the counters, not a tolerance chosen to make a check pass; anything beyond it is a tick counted twice or lost, which concurrency does not explain.

A check that reports healthy behaviour is one people learn to ignore. This project has said so since the interleaved-line detector, and I wrote one anyway within an hour of quoting it.

What S-P5 will be judged against

max_wait - the longest a task waited between becoming runnable and running - is recorded here because this is the only moment both halves are known. It is the number that says whether anybody is starving, and it is deliberately the worst case rather than the last one.


S-P5 — Policy (the layer, done 2026-09-02)

The phase that makes it a scheduler rather than a rotation. It is last on purpose: a policy decides which of several correct choices to make, so it can only sit on a lifetime layer that is correct, and it can only be evaluated with the accounting from S-P4. Built earlier it would have been tuned against a defect.

kernel/sched/sched_policy.c is a pure function of state the caller supplies - which slots are runnable, which core is asking. No task table, no lock, no clock. Every rule is therefore settled by a host test rather than by watching a boot and forming an impression, which is the only way a policy can be argued about at all.

What it decides

  • The quantum is a number somebody chose. Preemption happened whenever the timer fired, so the slice was "the timer period" - nobody's decision. It is now per class, in ticks, with the reason written next to each.
  • Classes are absolute. A runnable kernel task runs before any normal one, however long the normal one has waited. That is what makes a class different from a large weight. Idle is a class rather than a flag, so an idle task competes by the same rule instead of needing a special case in the picker.
  • Weights come from nice, as a table rather than a formula - the property that matters, roughly 25% per step over a range of about a thousand, is readable in a table and is not readable in an expression.
  • A new task is admitted level with its class, not at zero. Admitting at zero hands every newly created task the whole machine until it catches up: not a subtle unfairness but a fork bomb that needs no malice.
  • Renicing does not reset history. Otherwise a task can have the machine for free by renicing itself.
  • Affinity is checked in the picker, so a pinned task cannot appear elsewhere even when it is the only thing runnable.

The tests measure rather than inspect. They run the picker for tens of thousands of ticks and count who got what: nice 0 against nice 5 must land between 2.7:1 and 3.5:1, which is the weight ratio doing its job rather than a number read off the table. And every rule was confirmed by breaking it - class ordering, weighting, affinity and level admission each produce their own named failure when removed.

The first run found a real starvation bug

Virtual time advanced by (ticks * WEIGHT_BASE) / weight in whole units. For any task more favoured than nice 0 the weight exceeds the base, so that division truncates to zero: a nice -5 task accumulated no virtual time at all, always held the smallest, and ran forever. Not a rounding error - total starvation of everything else.

Virtual time is fixed point now, ten bits of fraction, which puts the smallest step (nice -20) at 11 instead of 0 and leaves four orders of magnitude before a 64-bit overflow.

A mistake worth recording: the header collided with an existing one

The first version of this layer was written to include/vibeos/policy.h, which already existed - it is the security policy, capabilities and MAC. The file was overwritten. It was restored from git intact and the scheduling one renamed to sched_policy.h.

The tell was there and was read past: the editor reported the file as updated rather than created. Check whether a name is taken before taking it, and read what a tool says it did rather than what it was asked to do.

Still open in S-P5

The layer exists, is tested and is not yet wired into hw_pick_next. Steps 4's work stealing and step 5's priority inversion are untouched - the latter is decision T7 and is not mine to settle.


Fork-bomb protection (2026-09-02)

The failure this closes is not that fork can fail. It already could, and returned an error as it should. It is what running out of slots meant: the task table is first-come, so a program forking in a loop took every slot, and then init could not start a service and the shell could not run a command. The machine stayed alive and became unadministrable, which is worse than one that refuses.

kernel/sched/forkguard.c answers one question - may this task create another - from counts the caller supplies, so the rules are settled by host tests instead of by writing a fork bomb and hoping it reproduces.

Two rules, and neither is interesting alone:

  • A reserve. Four slots, one per core, that only privileged tasks may take. Without the ceiling below, a bomb still fills every unreserved slot and every ordinary program stops working.
  • A ceiling. Eight live children per task. Without the reserve above, enough cooperating processes reach the same place.

Both numbers are chosen rather than discovered, and the reasoning is written where they are set: four is the smallest reserve that leaves every core able to start something, and eight is comfortably more than anything in this boot forks

  • the shell's deepest pipeline is three - so the limit binds on a bomb and on nothing else. A healthy boot refuses zero forks.

The refusal says which rule it was, because both return EAGAIN and they are completely different investigations: one is a program misbehaving, the other is a machine that is full.

[SCHED] fork refused reason=too-many-children pid=0x19 children=0x6 slots_in_use=0x11

Confirmed by tightening the child limit to two, which makes an ordinary boot trip it six times with the reason, the pid and the counts. Live children are counted by walking the table rather than kept in a field: a counter has to be right on every exit path including the ones that fail half way, and the table is twenty-four entries long. Zombies count, because a zombie still holds a slot.


S-P6 — Acceptance (2026-09-03)

Fork storm — done. user/prog/svc_bomb.c forks until the kernel refuses, reports how far it got and which rule stopped it, reaps every child and exits. Its children exit immediately rather than sleeping, because a storm of zombies is the harder case: a zombie still holds a slot, so the parent must reap them all or the table stays full for everybody else.

What the gate asserts is not that the storm ran but that it was stopped and cleaned up after - SVC_BOMB_DONE prints only once every child is reaped - and SVC_BOMB_UNBOUNDED must never appear, which is the guard silently not working while every other line still looks healthy. The real assertion is everything the boot does afterwards: the shell still runs commands and the kernel CLI still answers, on a machine a program has just tried to fill.

It is last in the manifest so that "afterwards" means something.

The state machine is asserted — done, and was already. illegal_transition is on the MUSTBEZERO line and the gate fails on it; that is how blocked -> blocked by=hw_sys_waitpid was found.

Soak — partly done, and stated rather than claimed. The gate asserts vmas_live returns to zero, frames_leaked and frames_double_put are zero, and the CPU-time identity balances. It does not assert that the task counters return to their starting values, because "starting" is not a thing this boot has - init and the services are still running when the counters are read.

Thread churn during exec — not done. TFORK.ELF forks with a thread running and is gated, which is half of it; threads created and joined while the process execs is the other half and does not exist. Left open rather than declared, because it is the combination that produced the execve defect and a criterion nobody exercises is worse than one nobody wrote.

A defect this phase found in one of my own checks

clang-warnings counted every line containing "warning:", which is mostly the linker discarding a build-id note - nineteen on a clean build, none on an incremental one. The number moved between 0, 1, 2, 3 and 19 for reasons that had nothing to do with the code.

CLAUDE.md says to treat a moving warning count as the build telling you something. That only works if it is telling the truth, and this one was not: it is filtered now, and reads 0.

Status: closed

S-P4 (the machine can say where its time went), S-P5 (classes, nice-weighted fairness, affinity), the fork-bomb guard and S-P6 (an enforced quantum, threads reaped where they die) are done and gated. 16 clean boots out of 16 across the Release and Debug configurations, which is the best this project has measured.

The one thing the plan raised and did not do is T7: no lock may be held across a scheduling point. It was attempted with a per-core lock-depth counter, and the attempt inserted a field into hw_cpu_t that shifted the offsets isr.S reads at %gs:8 and %gs:16 - a #GP at rip=0x39 on the next boot. It was reverted whole, and hw_sched_point() carries a comment saying why it is not implemented rather than a stub that looks like it is.

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.

Tasks and Scheduling (as of 2026-09-29)

S-P0 to S-P6 done, with two gaps named rather than papered over (docs/sched/). A transition table that refuses illegal state changes, tenancy on every task reference, a tested run queue, lifetime, accounting charged to exactly one place per tick, a policy of classes and nice-weighted fairness and affinity driving the picker - measured over tens of thousands of ticks, every rule confirmed by breaking it - a fork-bomb guard on both fork and clone, and a fork-storm service the gate asserts is stopped and cleaned up after. Still open: the quantum is defined and not enforced, so preemption is still whatever the timer does; and thread churn during exec is not exercised

S-P6 - the quantum is spent, and a thread is not a zombie

Two gaps were named when S-P5 closed, and this is both of them.

The time slice is spent, not merely declared

The scheduler computed a slice per class and then re-picked on every timer tick, so the slice was a number nothing read. hw_schedule now decrements slice_left and returns without re-picking, unless a higher class became runnable.

Gated rather than inspected. The boot gate compares accounted ticks against scheduler runs: with the quantum enforced a boot measures ticks=491 runs=82, and with the check removed it measures 335/335 - one run per tick, which is what "the slice is a number nothing read" looks like from outside. The assertion was confirmed to go red by disabling the quantum.

A thread has no reaper, so it must not become a zombie

tests/linux/musl_texec.c was written to cover the one combination nothing exercised: threads created and joined while children exec. It found a defect on its first run, and the defect was not in exec.

An exiting task became a ZOMBIE and waited to be reaped. waitpid matches on ppid, and a thread inherits its creator's parent rather than becoming its child - so the thing that joins it cannot wait for it, and nothing else is looking. Every thread that ever exited held its slot for the rest of the boot.

The failure surfaced nowhere near the threads. A program creating and joining eight threads leaked eight slots; several commands later the shell's fork was refused on a machine with plenty of memory. Six too-many-children refusals in one boot, then a wedge. The fork guard added in S-P5 is what made it visible - before that the slots were simply gone, and the table quietly ran out.

Joining is already synchronised without a zombie: exit clears the word the joiner sleeps on and wakes the futex. The zombie was carrying an exit status nobody was able to ask for.

Threads are now reaped where they die, and the refusals go from six to zero.

Two things this cost, both worth writing down

A slot released outside the transition table stays RUNNING. The first version called hw_task_release directly, which is running -> free. The table refuses that, correctly, and logged one [TASKS] ILLEGAL line per thread - so instead of leaking a zombie the kernel leaked a task stuck in RUNNING, which is worse and quieter. The table was right and the fix was wrong. It goes through ZOMBIE now. Without that table the change would have looked like it worked.

One boot argued the opposite of the truth. Raising the task table from 24 to 32 slots measured a broken boot, and 24 measured a working one, so 32 was recorded as the cause. It was not: the leak was, and with the leak fixed 32 boots clean eight times out of eight. Two single boots on a machine with a known intermittent failure decided a question they cannot decide - which is this project's own rule about counts, applied to a sample of one.

What is verified

  • 8/8 clean boots with repeat-boot.sh.
  • TEXEC_OK rounds=8 execs=8 when the program is run.
  • Zero too-many-children refusals in a boot that previously had six.
  • Zero [TASKS] ILLEGAL transitions.

What is not

musl_texec is not in the boot script. It passes its own rounds, but running it in the boot wedges the guest in a later phase, and that is an open defect rather than a passing test. The gate asserts on it whenever it is started - keyed on the program having been started, not on the binary being present on the media, because those are different questions and the difference already cost a run.

Scheduler torture, and the rule it is the first instance of

Status: in the nightly, four sabotage cases, all of which turn it red.

The rule

Every kernel module gets an intensive test in the nightly. Not the host tests — every module has those and they check the cases somebody thought of. Intensive means long, randomised, and checked against something other than the code under test.

check-nightly-coverage.py enforces it. A job says what it covers with a # module: <names> comment, and the count of uncovered modules may only go down. Seven of thirteen had nothing when the rule was written: exec, fs, io, ipc, object, proc, time.

What it tortures

Random sequences of admit, forget, renice, reaffine, charge and pick, with every pick checked against a model kept independently in flat arrays:

  • class dominance — a runnable KERNEL task runs before any NORMAL one, however long the normal one has waited;
  • affinity — a slot is only picked on a core its mask allows;
  • admission — a slot the policy never heard of, or has forgotten, is never picked;
  • liveness both ways — nothing eligible means nothing picked, and something eligible means something is;
  • weighting — a favourable nice accumulates virtual time more slowly.

The model is deliberately dumb: flat arrays, no cleverness. A model that shares an idea with the thing it checks stops being independent, and the defects worth finding here are the self-consistent ones. This kernel has had one: a task's class was derived from is_idle, which silently collapsed KERNEL into NORMAL, so a kernel task could not outrank anything — and it was consistent with itself for months.

Two things that had to be fixed in the test, not the kernel

It reported nine defects that were not there. On the first sweep, nine seeds of a hundred and fifty failed the weighting check. They were the model's fault: nice can change mid-run, so the model held the final value while the virtual time had accumulated under earlier ones, and a forget-then-readmit resets the policy's clock while the model kept adding. A slot only counts for that comparison now if it has been admitted once, never reniced since and never forgotten.

And then it could not observe the property it was checking. With the model fixed, the weighting check waited for two slots to arrive by chance with the same class, the same charged total and different nice. Sabotaging the charge to ignore the weight entirely — so nice stopped mattering at all — was caught on zero seeds out of a hundred and fifty.

That is this project's "right about the outcome, wrong about the mechanism", and the only thing that told the two apart was breaking the scheduler on purpose and watching the test stay green. The comparison is constructed now — fresh slots, same class, charged identically, nice values chosen — and the same sabotage is caught on 150 of 150.

The three picker properties were caught 40 of 40 from the start. That contrast is what made the fourth's silence worth chasing rather than shrugging at.

The four sabotage cases

brokencaught on
class dominance removed from the picker40 of 40 seeds
affinity ignored40 of 40 seeds
a slot the policy never heard of is scheduled40 of 40 seeds
the charge ignores the weight150 of 150 seeds

Clean tree: 0 of 150.

What the nightly runs

400 seeds at 6000 rounds under AddressSanitizer and UBSan, then five seeds at 400,000 rounds. Two different things: many seeds reach orderings nobody thought of, and long runs reach the ones that only appear deep into a sequence. The sanitizer is the other half — the model catches wrong arithmetic, the sanitizer catches walking off the slot table while doing it.

Every run prints its seed on the first line, so a failure is replayed with vibeos_sched_torture <seed> <rounds>.

System Call Interface Progress

Status: In Progress (Linux process semantics verified from ring 3) Last review: 2026-08-25

Implemented

  • Portable, host-tested handlers (docs/abi/ A2, 2026-09-29). kernel/abi/linux/ reaches the kernel only through include/vibeos/ksvc.h, a services interface written for every personality rather than for Linux; the architecture implements it in kernel/arch/x86_64/ksvc.c and the host tests in tests/kernel/ksvc_fake.c. tests/kernel/linux_abi_tests.c runs the handlers through linux_syscall, and every PARTIAL row of the registry carries a gap expectation that fails until its phase closes it. check-abi-layering.py keeps it so. The details are in docs/abi/phases.md, A2.
  • Descriptors as Linux has them (docs/abi/ A3, 2026-09-30). A descriptor names a counted open file description (include/vibeos/file.h), so dup, dup2, dup3 and fork share its offset and flags; a process has up to 1024 of them, 0-2 included, with FD_CLOEXEC honoured at exec; fcntl (no record locks) and close_range are served. Each file type - regular file, directory, pipe end, socket, console - has its own operations in kernel/abi/files/, which any personality uses. Details in docs/abi/phases.md, A3.
  • Paths (docs/abi/ A4, 2026-09-30). Each process has a working directory and a root; a path is normalised against them and walked through the mount table by kernel/fs/path.c, which every personality shares. chdir, fchdir, getcwd, mkdirat and unlinkat are served, every *at call honours its directory descriptor, exec resolves relative paths, and the dynamic loader is opened at the path PT_INTERP names - the substitution to EFI/BOOT/LDMUSL.SO is gone. Details in docs/abi/phases.md, A4.
  • Layouts and numbers from Linux's headers (docs/abi/ A5, 2026-09-30). The structures the Linux handlers copy to and from user memory, and the constants they compare arguments against, are declared once in include/vibeos/linux_layout.h instead of as byte offsets and bare numbers in each handler. tests/kernel/linux_layout_tests.c compares every field, size, errno value and constant with the host's uapi headers (the C library's for what uapi does not export), and check-linux-layout.py fails the build when a declaration has no comparison. Details in docs/abi/phases.md, A5.
  • IPC, L7 (closed 2026-10-12). Six steps (docs/abi/phases.md), measured against tests/corpus/ltp-l7.txt (0 of 81 on the parent). Steps 1 to 5, the 21 calls in five files under one lock: ipc.c - Linux's numbering (an index handed out cyclically, as since 5.4, and a sequence that moves when the cycle restarts; msg_next_id and its kin), ipcperms, the STAT and INFO commands, message queues by type; sem.c - semop whole or not at all, waiter counts, SEM_UNDO given back at the process's end; shm.c - a segment is an unnamed /tmp file, an attachment a shared mapping whose regions carry the segment's number, so the attach count is read off every process's regions rather than kept; mqueue.c - messages by priority in pages the sender allocates, mq_notify by signal; splice.c - through a kernel page and vibeos_pipe_peek, so a short write loses nothing. /proc takes files from its owner and its tunables are written as Linux's are. The registry's sixteen tables became 32 (a seventeenth was a panic at boot). Step 6: tests/corpus/sysvipc.c, a producer and consumer over SysV shared memory and semaphores and a round trip through each kind of queue, under musl and glibc, compared with Linux; the run found vmsplice waiting to put in everything, where Linux puts in what fits. LTP 64 of 81. Left: SIGEV_THREAD (netlink), a 4 KiB pipe, a sleep of at least a tick, fields x86-64 does not have, shmctl01's fork refused with EAGAIN (unexplained). Open and not L7's: THREADS.ELF's C6_SCHED failed once in about eighteen boots, a pinned child not moved to its core in three seconds; the stage now prints where it was.
  • Threads and scheduling, L6 (closed 2026-10-12). Four steps (docs/abi/phases.md), measured against tests/corpus/ltp-l6.txt (16 of 67 on the parent). Step 1: futex finished - bitsets, requeue, WAKE_OP on a fault-tolerant user compare-exchange (vibeos_uaccess_cmpxchg32), the futex2 calls with a sleeper on up to 128 words, robust lists walked at exit; THREADS.ELF's C6_ROBUST and C6_COND at boot. Step 2: the sched_* family over a REALTIME class added to the scheduler's policy (priority before virtual time; the torture's model learned it), affinity over the pickers' mask, getcpu, sched_rr_get_interval, ioprio, membarrier through the shootdown IPI; C6_SCHED. Step 3: clone makes a process on its parent's memory - posix_spawn and vfork work - with CLONE_VFORK, CLONE_PARENT and the tid words for a forking clone, and the task table is 64 slots; C6_VFORK. Step 4: a thread pool under musl and glibc in the corpus, compared with Linux, which found three defects: a fatal signal ended one thread rather than the process (now hw_task_kill_group, C6_ABORT), the TLB quarantine leaked frames when full (a shootdown releases them), and the mm lock's waiters did not answer a flush under TCG. LTP 53 of 67; what is left needs more than 64 tasks, SCHED_DEADLINE, or another phase.
  • Sockets, L5 (closed 2026-10-06). Four steps (docs/abi/phases.md). Steps 1 to 3: a socket type carries a neutral table of socket operations (include/vibeos/sockops.h) - bind, listen, connect, accept, sendmsg, recvmsg, shutdown, names, options - filled by IP (kernel/abi/files/socket.c, over a stack that learned addressed binds, EADDRINUSE and SO_REUSEADDR, ephemeral ports, connected UDP, MSG_PEEK and MSG_TRUNC) and by local sockets (kernel/abi/files/unixsock.c: stream and datagram, a socket node in the filesystem through a new VFS mknod or a name in the abstract namespace, SO_PEERCRED, SCM_RIGHTS carrying A3's counted descriptions); the options every socket keeps alike live in the description (sockopt.c); kernel/abi/linux/net.c only translates sockaddr, msghdr, cmsg and mmsghdr. readv and writev on a socket are one receive or send. Step 4: BusyBox wget against the host, a DNS lookup through musl's resolver and QEMU's, and SOCKETS.ELF's checks - a Unix-socket server and client among them - at boot (tests/corpus/run-l5.sh). Its LTP run found there was no loopback: the stack has one now (a ring the timer's poll feeds back to the input path), and a SYN to a closed port is reset; its boots found that a half-closed connection was reclaimed under an open descriptor (L4's nc check had been green on timing), and it waits for the close now. LTP for the phase 44 of 64 (26 before the fixes); the rest network namespaces, IPv6/SCTP/RDS, SysV semaphores, O_PATH, MSG_MORE corking.
  • Event loops, L4 (closed 2026-10-06). The plan, in seven steps (docs/abi/phases.md): one readiness engine for poll, ppoll, select and pselect6, with sockets saying what they can do; eventfd; timerfd; signalfd; epoll; inotify; and the programs - BusyBox httpd serving from the guest, nc, tail -f - with LTP's 78 tests for L4's calls (tests/corpus/ltp-l4.txt). No wait queue: every wait here looks, gives up the core and looks again, and the event loops ask each description's ready(). Step 1 (2026-10-05): poll, ppoll, select and pselect6 on one engine (kernel/abi/linux/poll.c), sockets that say what they can do (vibeos_inet_ready), a wait never shorter than asked, and EVENTS.ELF at boot. Steps 2 to 4 (2026-10-05): eventfd (a counter in the description, changed by compare-exchange), timerfd (a deadline and a period counted by whoever looks - nothing fires it) and signalfd (pending signals taken as Linux's records), each a file type with a ready() and a pipe's wait; LTP 16 of 20, the rest a time namespace, libaio and a fuzzy-sync race that needs L6. Step 5 (2026-10-05): epoll (kernel/abi/linux/epoll.c) - poll with a set that outlives the call: entries belong to the description and go with its last reference (vibeos_file_on_release), level, edge (a rise in ready), one-shot, nested up to five, ELOOP for a cycle; LTP 16 of 25, then epoll_ctl04 fixed (the nesting chain counted only below), the rest socketpair (L5), listen unbound and F_SETPIPE_SZ. Step 6 (2026-10-05): inotify (kernel/abi/linux/inotify.c) on vibeos_fs_notify, which the file layer calls once per operation on a name and the regular-file type for open, read, write and close; a watch names a node. Its LTP run found that a process renaming its own working directory lost it (cwd is a path here): a renamed directory now takes working directories, roots and open descriptions along. Step 7 (2026-10-06): the programs at boot (tests/corpus/run-l4.sh) - tail -f, BusyBox httpd fetched from the host through a forwarded port, nc both ways - with setsockopt (SO_REUSEADDR, SO_KEEPALIVE) and shutdown taken from L5; the TCP flush no longer counts a FIN as data (it looped for ever with the network lock held), and a wait on a socket hears a signal. LTP for the phase 58 of 83.
  • Processes, credentials and time, L2 (closed 2026-10-04). All seven steps. Step 1: a process has real, effective, saved and filesystem ids and supplementary groups (include/vibeos/cred.h, kernel/sched/cred.c); the sixteen get and set calls; and L1's file operations ask who is asking - search permission in the walk, read/write on open, the directory's permission and the sticky bit for names, ownership for chmod/chown/times, with access answering for the real user. Registry: 143 done, 23 partial. LTP's L1 list after it: 154 passed (131 before it), 16 failed, 144 broken, 65 not applicable, 2 did not run. What is left is mostly a loop device, alarm, and files under /proc and /dev. Step 2: signals as Linux delivers them - a siginfo_t and ucontext frame with the vector registers, read back by rt_sigreturn; a fault given to a handler that asked for it; sigaltstack, rt_sigpending, rt_sigsuspend, pause, rt_sigtimedwait, rt_sigqueueinfo, rt_tgsigqueueinfo; SIGCHLD when a child ends. LTP after it: not measured in this step. The first test of the signal list, pause01, waits for its child to show as sleeping in /proc//stat (step 6), and LTP's own timeout is setitimer (step 3), so the first test that waits forever takes the rest of its boot: 3 of 48 reported (kill08 passed; kill05 needs System V shared memory; kill06 failed with ESRCH killing a process group, not yet looked into). Measured again with step 3, when the timeout works. Step 3: timers - alarm, the three interval timers and POSIX timers on the machine's and the CPU's clocks, fired by the tick from a portable table (kernel/sched/ptimer.c); clock_getres, gettimeofday, times. LTP after it: the timer tests and step 2's signal tests together (tests/corpus/ltp-l2.txt's subset, 70 tests): 37 passed, 1 failed (times03: no system time), 24 broken, 7 did not run (kill10 takes the rest of its boot: step 5). Before this step the same list ran 3 of 48. Of the broken: /proc//stat and /proc/cpuinfo (step 6), setrlimit (step 4), clock_settime refused, System V shared memory, signal 64, and LTP's musl restorer. Step 4: resource limits in the process state, NOFILE, FSIZE, DATA, NPROC and CPU enforced; getrusage, getpriority/setpriority, personality - and the scheduler's policy, never initialised on the machine since it was written, now runs (M-084). LTP after it: the limit tests (22): 17 passed; the rest need 512 MB free (getrusage03), /proc/cpuinfo (getrusage04, step 6), sched_getaffinity (nice05, L6), select (personality02), /bin/true on the volume (setrlimit04), and useradd (setpriority01, which then waits out its timeout - not looked into). With step 3's 70 alongside: 54 of 94 passed. Step 5: one engine under wait4 and waitid (by pid, group or pidfd; WNOWAIT), pidfds (kernel/abi/files/pidfd.c), clone3 and execveat. LTP after it: the step's own 41 (build-gcc-Release/ltp-l2s5.txt): 12 passed, 2 failed, 26 broken, 1 not applicable. It found two defects in this step's clone3 - the exit signal a caller names is ignored and SIGCHLD sent instead (clone301), and five of the argument checks Linux makes are missing (clone302) - and two in older layers that decide most of the rest. Eight tests waited out their timeout; waitid04, the one read, passed its checks and then hung in LTP's checkpoint, a futex shared between processes through a MAP_SHARED page, which the futex table keys per process. And an orphan is never given to init here, so the children of a test killed by its timeout stay zombies nobody reaps: the task table filled, and every fork after that was EAGAIN (seven tests). All three are fixed in the next commits. The rest want /proc (step 6), unshare, cgroups, a loop device, core dumps, ns_last_pid, or LTP resources that are not staged (execveat01, execveat02). Step 6: /proc about processes (self, /stat, status, cmdline, maps, fd/, exe; cpuinfo, mounts), /dev (null, zero, full, random, urandom, tty), a ChaCha20 pool behind getrandom; and the three defects step 5's LTP found - a futex shared through a MAP_SHARED page, orphans never adopted by init, clone's exit signal. LTP after it: 41 of 79 passed (ltp-l2s6.txt), step 5's 41 alone 23 (12 before); it found orphaned process groups never sent SIGHUP and SIGCONT, which step 7 takes. Step 7, the programs: eight BusyBox workloads more in the corpus - traps, kill and wait, timeout, time, ps, /proc, /dev, limits - compared with Linux; kill's session rule replaced by Linux's (BusyBox's timeout); a stop holds until SIGCONT or SIGKILL; orphaned process groups with a stopped member hung up; /proc/ gives the leader's state, and /proc/ and task/ exist. LTP for L2's own 188: 106 passed, none left unrun.
  • Memory, L3 (closed 2026-10-02). Step 6, the programs: SQLite's shell and Lua built against glibc and dynamically linked run with the host's own ld-linux-x86-64.so.2 and libc.so.6 staged on the boot volume; SQLite runs in write-ahead-log mode with mmap_size set, which needs a shared mapping of a file. Eighteen corpus workloads, compared with Linux's answers. LTP for L3's syscalls: 31 of 63 pass, with two left out because they hang (mincore04, mmap18). It found three more wrong answers, fixed: MAP_SHARED_VALIDATE did not refuse an unknown flag, MAP_LOCKED was ignored, and msync(MS_INVALIDATE) of a locked page was not EBUSY. Not fixed and said: a fault in ring 3 kills the task instead of delivering SIGSEGV or SIGBUS to a handler that asked for it (mmap05, mmap13, and why mmap18 hangs); mincore03 expects an untouched page not to be resident; mremap01 aborts after mremap refuses to grow a shared mapping of a file - most likely in its own cleanup, which was not confirmed. The rest want setrlimit, /proc/self/maps and status, shmget, seals, mount or a loop device. Steps 1 to 5: Step 4: madvise (a discarded private page reads zeros; speed advice is a no-op; advice this kernel cannot keep is EINVAL), mincore, mlock/mlock2/munlock/mlockall/munlockall (a mark in the entry that page-out refuses), mremap (shrink, grow in place, move - entries moved, not copied) and memfd_create (a /tmp file that goes with its last descriptor). Registry: 132 done, 24 partial; the four new partial rows name their gaps. Step 3: LTP's 382 tests for L1's syscalls run through scripts/dev/ltp-run.sh (131 passed, 10 failed, 177 broken, 57 not applicable, 7 did not run). To start at all they needed /proc (kernel/fs/procfs.c: meminfo, sys/kernel/pid_max), /etc/passwd and /etc/group, nanosleep/clock_nanosleep, and interrupted calls run again under SA_RESTART. They found and this fixed: F_GETLK's choice of lock, an unjudged read of a path's first byte (M-082), ENOENT for an empty path, EINVAL and EISDIR in preadv, EFBIG in fallocate, the description pool, socket's errno. What is left is mostly L2's: credentials, alarm, /dev. Registry: 127 done, 20 partial. Step 2: MAP_SHARED - a shared page is marked in its entry and a fork maps the same frame into the child instead of making it copy-on-write; a shared mapping of a file maps the filesystem's own pages (share_page, tmpfs only; FAT answers ENODEV), so the mapping and read/write are one memory; msync validates and returns; a shared page is never paged out. Registry: 125 done, 20 partial. Step 5, taken early: the user stack grows - four pages at exec, a zeroed page for any address in the two megabytes below that the program or the kernel reaches, a guard page under them; the range check accepts an untouched stack page; the gate asserts stack_grown and a ring-3 self-test with a hundred-kilobyte frame. It replaced the 64 pages L1 step 8 mapped at exec, which had made the open swap defect M-070 frequent enough to make the gate flaky. Step 1: mmap places a mapping at a fixed address (MAP_FIXED, MAP_FIXED_NOREPLACE), honours a free hint, steps its arena over what is already mapped, and maps a file privately - the file's bytes in the process's own pages. The fake kernel gained real page tables (kernel/mm over static memory), so the memory handlers are host-tested for the first time. Shared mappings are step 2, and are what LTP's harness waits for. On the way: a race in fork - a read-only page evicted between fork's read of its entry and fork's reference - found and fixed in kernel/mm/vmspace.c; and M-070, the swap-slot double release, seen three times in about sixty boots since L1 step 8 and still open, now with instrumentation that says why a page-in failed.
  • Files and paths, L1 (closed 2026-10-01, LTP pending L3). All eight steps. Step 8: tests/corpus/run-l1.sh runs fifteen corpus workloads in the guest - twelve BusyBox ones, SQLite on a file and in memory, a Lua script - and the gate compares every line with what the same script printed on Linux (tests/corpus/l1-expected.txt, written by scripts/dev/corpus-expect.sh). Running them found four defects outside the step's syscalls: execve refused /proc/self/exe (how BusyBox's shell runs its applets), a forked child kept the slot's previous program path, the user stack was 16 KiB (now 256, still not growing), and FAT files were reported non-executable. LTP is staged by scripts/dev/ltp-run.sh and every test breaks at its harness's mmap(MAP_SHARED) of a file: L3. Step 7: the console is a terminal (include/vibeos/tty.h): modes in Linux's numbering, ICANON, ECHO, ICRNL and ONLCR honoured, a line discipline that waits for a finished line or hands over bytes as typed; TCGETS, the TCSETS forms, the window size, and FIOCLEX/FIONCLEX/FIONBIO/FIONREAD. A terminal made BusyBox's shell interactive, which needed poll (minimal, PARTIAL, L4's) and a writev gathered into one write. Found on the way: the native shell's write had made an empty file since step 4 and nothing asserted its contents - now notes_file_not_read_back. Step 6: advisory locks in a personality-neutral table (kernel/fs/filelock.c) - fcntl's record locks owned by a process, the OFD forms and flock owned by a description, EDEADLK for a wait that could not end, POSIX's close-drops-everything rule - tortured nightly against a model; directories read by position through one readdir operation and formatted by the personality, so getdents64 lists whole names with their real type and inode and getdents is the same reader in the older record; the twelve xattr calls; close_range's UNSHARE for a single-threaded process. 124 done, 19 partial. Step 5: names and metadata in kernel/abi/linux/names.c - stat/lstat/statx, the access family, rename*, rmdir, link*, symlink*, readlink, the chmod, chown and utime families, statfs/fstatfs, mknod*, openat2 - thirty-two rows, the registry at 108 done and 21 partial. What Linux decides before a filesystem is asked (EXDEV, EBUSY for a mount's root, a directory into itself) is decided in the handler; st_dev is the mount, so two filesystems are two devices; the shell's mv, ln, chmod and stat are gated at boot. Step 4: the FAT driver (kernel/fs/fat.c, out of the architecture) writes at an offset, truncates, creates long names, renames, removes directories and grows them; a node is identified by where its directory entry sits; the 512-byte whole-file path in kernel/abi/files/regular.c is deleted and pwrite64, pwritev, truncate, ftruncate are done (79 done, 18 partial). Proved by host tests, by mtools reading a volume the driver worked on (scripts/dev/verify-fat-mtools.sh), by scripts/dev/fat-fsck.py, and at boot: the shell writes on the boot volume and the gate checks the image once the machine has stopped. (Step 3: writes through a descriptor at an offset, O_CREAT/O_EXCL/O_TRUNC/O_APPEND/O_RDWR, the umask, and twenty rows from pread64 to copy_file_range; the registry is at 75 done and 22 partial). Steps 1 and 2: the filesystem interface carries what stat reports and has write-at-offset, truncate, create, rmdir, rename, link, symlink, readlink, setattr, statfs and sync as optional operations; vibeos_path_walk follows symbolic links; and kernel/fs/tmpfs.c is a complete in-memory filesystem at /tmp, host-tested and tortured nightly against a model. The syscalls themselves come from step 3 on. Details in docs/abi/phases.md, L1.
  • Real on-metal ring-3 -> ring-0 syscall entry (kernel/arch/x86_64/arch_hw.c + isr.S), two paths into one Linux-ABI dispatcher (vibeos_x86_64_linux_syscall):
    • Native syscall/sysret (the real Linux x86-64 fast path): EFER.SCE enabled, STAR/LSTAR/SFMASK programmed, and an LSTAR trampoline that switches to a kernel stack, marshals the Linux ABI (nr in rax; args in rdi/rsi/rdx) into the C ABI, dispatches, and sysretqs back to ring 3.
    • int 0x80 gate (DPL 3) as a legacy/bring-up path into the same dispatcher.
    • User GDT segments ordered data-then-code (for SYSRET) + a TSS with RSP0.
    • Linux syscall translation covers the runtime subset used by the user programs, including console I/O, process lifecycle and FAT-backed file operations; unsupported calls return -ENOSYS and are named in the serial log, so a missing number is visible rather than silent.
    • The opening sequence of a C runtime. A static libc runs a fixed set of syscalls before it reaches main and dies if any of them fails. Implemented: arch_prctl (ARCH_SET_FS/ARCH_GET_FS), set_tid_address, set_robust_list, gettid, ioctl, writev, readv, uname, clock_gettime, prlimit64, futex, sched_yield, rt_sigprocmask, rt_sigaction, and the getuid/getgid family. rseq and getrandom return -ENOSYS deliberately: both have mandatory fallbacks, and a fake success would be worse than a refusal.
    • Thread-local storage is real. arch_prctl(ARCH_SET_FS) writes MSR_FS_BASE, the value is per task, and it is reloaded on every context switch - without that, one program reads another's thread state. A base outside user memory is refused before the write, because a non-canonical value would fault in ring 0. Verified from ring 3 by reading the TLS self-pointer back through %fs:0 after the task has been preempted and rescheduled many times, not by checking that the syscall returned zero.
    • mmap, mprotect and munmap act on the page tables. mprotect grants and revokes write permission on the real entries and flushes the TLB; munmap unmaps and frees the frames, so a use-after-unmap faults here as it would on Linux. MAP_FIXED and file-backed mappings are refused rather than quietly ignored - a program that asks for a specific address and silently gets another one corrupts itself later, far from the call.
    • What a program does once it is running, beyond the startup sequence: fstat, newfstatat, openat, getcwd, readlinkat, prctl, setuid/setgid, getppid, time, and clone for the flags that mean fork. These are the calls that separate "a libc starts" from "a program works": BusyBox reaches the filesystem through openat, fstat, read and getdents64, and the boot fails on busybox_file_operations_failed if any of them regresses.
    • Signal delivery is implemented, not stubbed. rt_sigaction and rt_sigprocmask install real dispositions and masks; kill, tkill and tgkill raise; a raise sets a pending bit and wakes a task blocked in read(). Delivery happens on the way back to user space, where the process's own stack exists: the frame goes below the 128-byte red zone, aligned as a call site, with the C library's SA_RESTORER trampoline as the return address. rt_sigreturn restores that frame but forces cs, ss and rflags back to safe values and checks a magic word first, because the frame is user-writable and nothing read out of it may decide privilege. SIGKILL and SIGSTOP are neither catchable nor blockable. Linux sigset_t bits are numbered from zero (bit 0 is signal 1) and are converted only in hw_sigset_from_user/hw_sigset_to_user. A wait status is not an exit code: exit code in the high byte, signal in the low seven bits. Gated - the boot fails on signal_delivery_broken if tests/linux/musl_signal.c, built by musl-gcc against a real libc, does not report SIG_OK.
    • Pipes and descriptor plumbing: pipe, pipe2, dup, dup2, inheritance across fork with the pipe's reader/writer counts adjusted, release on exit, and SIGPIPE plus EPIPE for a write with no reader. A pipe outlives its last writer so a reader can drain it. dup2 onto 0, 1 or 2 records a standard-descriptor redirection, since the console is not an entry in the descriptor table. Gated: ls /EFI/BOOT | wc -l runs in BusyBox's shell and the boot fails on pipeline_did_not_complete.
    • Verified in QEMU (GCC and Clang): a real user ELF is loaded (parsed by the portable, host-tested kernel/core/elf.c), run in ring 3, and prints via a native syscall, then exit returns cleanly to the kernel. This hardware front end will route into the portable dispatcher and the Linux personality (user/compat/linux) next.
  • Central syscall dispatcher in kernel/core/syscall.c (portable model).
  • ABI helper mappings in include/vibeos/syscall_abi.h.
  • Rights/policy lookup layer in kernel/core/syscall_policy.c.
  • Process/thread lifecycle syscall family.
  • Scheduler observability and runtime-control syscall family.
  • Waitset ownership/stats/wake-policy syscall paths.
  • Security/audit/policy control syscalls with kernel-vs-user authorization checks.
  • ABI version/compatibility syscalls (VIBEOS_SYSCALL_ABI_VERSION_GET, VIBEOS_SYSCALL_ABI_COMPAT_CHECK) with compatibility helpers in syscall_abi.h.
  • The on-metal dispatcher validates user pointers before dereference for the implemented Linux-ABI subset.

Pending

  • nanosleep and poll. A shell that waits on several descriptors at once needs poll; the pipeline path currently blocks on one at a time.
  • clone with CLONE_VM/CLONE_THREAD returns -ENOSYS, and real futexes belong with real threads rather than before them.
  • Signal gaps: no SA_SIGINFO siginfo_t, no alternate signal stack (sigaltstack), no rt_sigsuspend/rt_sigpending, and a handler installed without SA_RESTORER cannot be entered at all - the process is terminated instead of being given a kernel-supplied trampoline. A three-argument SA_SIGINFO handler is entered with its second and third arguments zeroed.
  • openat accepts only AT_FDCWD and there is no per-process working directory, so relative paths resolve against the volume root.
  • sendfile is refused; callers are expected to take their read-and-write fallback.
  • Copy-on-write statistics are printed ([MM] COW_STATS) but no gate asserts them.
  • An entropy source, so getrandom can answer and AT_RANDOM can carry something better than nothing.
  • Stronger multi-tenant authorization model beyond relation-based checks.
  • Syscall ABI versioning strategy with compatibility negotiation.
  • Per-syscall latency telemetry and regression thresholds.
  • Consolidate the hardware Linux-ABI dispatcher and portable syscall policy dispatcher behind one audited authorization boundary.

Next checkpoint

  • poll and nanosleep, so a shell can wait on more than one descriptor and a program can sleep without spinning.
  • Consolidate the runtime and portable dispatch paths, then add negative pointer/permission tests for every exposed syscall.

The user-access recovery

Status: FIXED (2026-09-11) - H-003 and H-010. The first attempt, and why it failed, is kept below: it is the reason the second one looks the way it does.

The defect

Validating a user pointer and dereferencing it are two instants, and nothing makes the pair atomic: hw_user_range_ok walks the page tables, and a sibling thread on another core can munmap the address before the read happens. A ring-0 page fault panics, so an unprivileged multi-threaded process could stop the machine by racing against its own memory.

H-003 found it on the futex path, where g_futex_lock can spin between the check and the read. H-010 found the wider shape: a syscall that validates its buffer and then blocks - pipe read and write, the console read, socket receive - leaves the window open for as long as it sleeps.

The fix

kernel/arch/x86_64/uaccess.S holds vibeos_uaccess_copy(dst, src, len): one rep movsb, bracketed by vibeos_uaccess_copy_begin and _end, and a recovery point vibeos_uaccess_copy_fixup that returns -1. When the trap handler takes a ring-0 page fault that copy-on-write did not resolve, at a rip inside that range, on a user address, it resumes at the recovery point. The stack is as the fault left it - inside a leaf, before any push - so ret returns to the caller with the error. A kernel address faulting there still panics.

Every site the two findings named goes through it, and a few they did not:

  • futex: the word compared under g_futex_lock;
  • exit: the join word written to clear_child_tid;
  • pipes: read and write, in contiguous runs of the ring buffer, consuming or producing only what was actually copied;
  • the console read, per character;
  • sockets: recv, recvfrom, send, sendto. The portable stack writes straight into whatever pointer it is given and cannot use an arch-layer routine, so data goes through a kernel bounce buffer under g_net_lock.

The syscall table's own write-out sites were swept in two batches. First (6a94a32) clock_gettime, execve's argv vector walk and poison probe, time(), and the crash-recorder stack read. Then the ones that batch's grep missed because they are not a single *(T*) deref: pipe2's two-fd write (which now rolls the pipe and both fds back on a faulting copy-out rather than leaking them), hw_write_stat (assembled in a kernel buffer and copied out once instead of filled field by field), getcwd, readlinkat, and prctl PR_SET_NAME (a copy-in) / PR_GET_NAME. Each keeps hw_user_range_ok as the address-policy check and adds the fault-safe copy for the race.

Other validate-then-use sites remain - there are dozens across the wider kernel - and each has the same theoretical window against a concurrent munmap. They do not block, so the window is a few instructions; they are the next place to apply the routine, not a claim that the class is gone.

How it is known to work

Bring-up forces a copy from the top of the high user window, which nothing maps, before any task runs, and prints uaccess recovery ok if it came back as an error. The boot gate asserts the line (uaccess_recovery_not_proven).

The first red was not a red. On the unfixed kernel the probe used a plain C copy into a local nothing read afterwards; the compiler removed it, and the probe reported a successful read of an unmapped address. The gate went red, for a reason that had nothing to do with the defect - exactly the failure the previous attempt's "three green boots" had been. The real red is a sabotage on the fixed kernel, the recovery branch switched off: the probe's copy takes a ring-0 page fault (vector 0xe, cs 0x8, rip 0x4000413 inside uaccess.S) and the gate reports guest_panicked (.boot-evidence/sabotage-uaccess.log).

Fixed: uaccess recovery ok, check.sh all green, twelve boots 12/12.

check-chokepoints.py moved hw_user_addr_ok from 2 to 4 call sites - the handler asks it before resuming - and the count was updated with that reason. It also showed that .f.sh printed that check without asserting it; it asserts every check it prints now.

The first attempt, and why it did not work

An exception table with one entry per core: hw_user_read32 recorded a recovery point with the GNU address-of-label extension, and the trap handler rewrote frame->rip to it. The recovered rip was 0x000000aa020 with the kernel linked at 0x4000000: under this freestanding link &&label produced a section-relative offset, and the handler resumed at an unmapped low address. Assembly symbols are ordinary relocations - the same kind every function pointer in the kernel is - which is why the fix is assembly.

That attempt measured 3/3 green before a probe existed, because its only caller always had the page mapped. A probe that runs from bring-up turned it into a #UD in one run; the first version of the probe ran from vibeos_kmain, after userland, with a CR3 that could belong to a torn-down address space, and the machine went silent instead. Bring-up, in the kernel's own address space, is where a probe like this belongs.

User Space Interface Progress

Status: In Progress (unmodified static Linux binaries verified from ring 3) Last review: 2026-08-28

Implemented

  • User API syscall bridge in user/lib/user_api.c.
  • Service-facing interface contracts in include/vibeos/user_api.h.
  • Compatibility runtime scaffolding under user/compat/*.
  • Baseline integration with process/security/syscall surfaces.
  • Versioned user API contract and capability matrix exposure (vibeos_user_api_contract, vibeos_user_api_capabilities).
  • Real ELF programs now run in ring 3 through the native syscall entry path. The runtime loads programs from the FAT boot volume and supports fork, execve, waitpid and exit.
  • Programs start on a real System V startup stack, not a bare one: vibeos_elf_build_stack (kernel/core/elf.c) lays out argc, argv, envp and the auxiliary vector, and user/prog/crt0.S is a genuine assembly _start that converts that state into a C call. AT_PHDR, AT_PHNUM, AT_PHENT, AT_ENTRY, AT_PAGESZ and AT_RANDOM are all populated, and the user linker script now maps the ELF headers inside the first PT_LOAD so AT_PHDR points at a program header table that really exists in the address space. A ring-3 program verifies this on every boot (auxv ok in the serial log).
  • Every user program now enters through a real assembly _start (user/prog/crt0.S) and is written as an ordinary vibeos_main(argc, argv, envp). This is not cosmetic: at entry rsp is aligned like a call site rather than a function frame, and the arguments are on the stack rather than in registers, so a C function as the entry point either misreads them or faults on the first aligned SSE store.
  • /BIN/SH supplies the current interactive shell baseline with keyboard/serial input, line editing and file commands; framebuffer output is available in the runtime console path.
  • Unmodified static Linux binaries run end to end. A static musl program and a static BusyBox - neither built by this project - load, reach main, and do real work. BusyBox dispatches on argv[0] (which is not the path: the shell supplies the name, the path only opens the file), runs echo, cat and ls, and its shell parses scripts, searches PATH, forks and execs. Gated: busybox_did_not_run, busybox_applet_dispatch_failed, busybox_file_operations_failed, interactive_shell_did_not_run, shell_script_did_not_run, unmodified_linux_binary_did_not_run and linux_binary_got_wrong_argv all fail the boot.
  • Pipelines run in that shell. pipe2, dup, dup2 and descriptor inheritance across fork are enough for ls /EFI/BOOT | wc -l; the boot self-test runs exactly that and the gate fails on pipeline_did_not_complete. Writing into a pipe with no reader raises SIGPIPE, which is what stops a pipeline filling memory after its reader has gone.
  • A signal reaches a real handler. A program built against a real libc installs handlers, blocks and unblocks signals, ignores one and lets another take its default action; the return path goes through the libc's own trampoline and rt_sigreturn. Gated on signal_delivery_broken.
  • Ctrl-C on the console raises SIGINT against the newest live user task. The signal itself is delivered like any other; picking the recipient by newest pid is the stand-in for process groups, which do not exist yet. Not covered by a gate.
  • Native userland ABI v1 is now defined in include/vibeos/userland.h, with fixed-width process start metadata, service manifests, runtime snapshots, and deterministic validation of versions, sizes, bounded strings, dependency masks and lifecycle enums.
  • A bounded native service supervisor is implemented in user/servicemgr/supervisor.c: it loads validated manifests, starts dependency-ready services, records runtime snapshots, applies restart limits/backoff and isolates terminal service failures. Host regression coverage exercises dependency ordering, restart and failure state transitions.
  • The portable process model now exposes validated process-group and session membership. This is a host-verified contract only; x86_64 task wiring and controlling-terminal job control remain open.
  • A freestanding native init candidate is built and staged separately from the legacy bring-up workload. Runtime selection and process-backed service startup remain pending.
  • External commands launched by the native shell now receive a distinct process group; the shell transfers and restores foreground terminal ownership around wait4. Job-control behavior remains unverified in QEMU.

Pending

  • Process groups and a controlling terminal, so Ctrl-C targets a job rather than the newest pid, and so ioctl(TCGETS) can become something other than ENOTTY.
  • A test that exercises the Ctrl-C to SIGINT path, which is currently demonstrated rather than gated.
  • Libraries with dependencies of their own. Dynamic executables load - the kernel places an ET_DYN image at a bias it chooses, maps the PT_INTERP interpreter alongside it and enters the interpreter - but one program and one interpreter is what has been shown, not a shared object that pulls in another.
  • Stable libc/runtime contract for richer userland.
  • Runtime wiring of the native ABI/supervisor to a filesystem-loaded /sbin/init process.
  • Expanded compatibility ABI translation depth (Linux/Windows/macOS targets).
  • Tooling and diagnostics for userland API evolution.
  • A stable libc/runtime boundary, executable format policy and user-space memory/error isolation.

Next checkpoint

  • Replace the fixed boot program selection with a supervised ring-3 init process and a stable userland runtime contract.

Windows Compatibility Progress

Status: W2 done - Wine's own tests run on our ntdll, msvcrt and kernel32 Last review: 2026-10-17

The plan (2026-10-13)

../abi/windows.md. The decision this file used to say came first is made: an NT personality in the kernel, beside Linux's and over the same services interface, with our own ntdll.dll above it (written from scratch, built with mingw-w64), handles and objects in a portable object manager in the kernel, and Wine's own conformance tests as the oracle. Steps W0-W7: the oracle, the PE loader, objects and waits, memory, files, threads and exceptions, processes, then kernel32 as the tests ask.

What exists (W2, 2026-10-17)

  • kernel/core/pe.c: the PE32+ parser and relocation, its answer in the ELF mapper's segment form; tests/kernel/pe_tests.c against an image mingw-w64 linked.
  • kernel/abi/nt_services.def, kernel/abi/abi_nt.c, kernel/abi/nt/: every NT service, the registry and the dispatcher; the object manager (handles are descriptors with access masks, a flat namespace of whole paths), events, mutants, semaphores, both waits, NtDelayExecution, NtOpenFile and image sections; tests/kernel/nt_abi_tests.c.
  • kernel/arch/x86_64/pe_load.c and the swapgs paths in isr.S: exec of a PE image, moved when it cannot be placed where it asks, DLL views, and a %gs base per task.
  • user/win/ntdll: the system call stubs, the loader (DLLs from C:\win, DllMain, named stubs for what no DLL exports, Ldr* for kernel32), and the Rtl runtime, host-tested in tests/win/ntrtl_tests.c.
  • user/win/msvcrt and user/win/kernel32: the C runtime Wine's tests import, and kernel32 as far as the tests reach before W3. user32 and advapi32 are still placeholders that export nothing.
  • scripts/dev/winetest-run.sh: Wine's tests on VibeOS, against Wine's answers.

What W3 added (2026-10-20 to 2026-10-21)

  • Regions with a state (include/vibeos/vma.h): present, committed and made on first touch, or reserved with no access; each carries its allocation's base and protection and the protection as Windows spelled it. The page fault makes a committed page (hw_stack_grow in mm_bridge.c), and the user range check accepts one nobody has touched yet.

  • kernel/abi/mem/umem.c: placing, releasing and RLIMIT_AS for every personality, lifted out of Linux's mm.c.

  • kernel/abi/nt/memory.c: NtAllocateVirtualMemory, NtFreeVirtualMemory, NtProtectVirtualMemory and NtQueryVirtualMemory; kernel32's VirtualAlloc, VirtualFree, VirtualProtect and VirtualQuery with their Ex forms. Host tests in tests/kernel/nt_abi_tests.c (t_virtual_memory); hello.exe uses them at boot. Gaps are listed in ../abi/windows.md, W3 step 2.

  • user/win/ntdll/rtl/heap.c: the process heap grows a segment at a time on NtAllocateVirtualMemory instead of living in a 512 KiB arena; msvcrt's malloc is on it. Host-tested with a randomised soak in tests/win/ntrtl_tests.c; hello.exe makes it grow at boot.

  • kernel/arch/x86_64/mm_bridge.c (hw_map_elf_image): an image's zero-fill .bss in the high window is left to the page fault; the boot counts the pages (pages_deferred).

  • kernel/abi/nt/section.c: data sections - of memory alone, shared by their views, or of a file, copied - NtOpenSection, NtQuerySection and NtUnmapViewOfSection; kernel32's file-mapping functions over them.

  • The reviews' memory findings: an image section read by its description's node (M-106), one address space shared as an object between CLONE_VM processes (vibeos_mm_t, M-100), and kernel stacks with guard pages and the double fault on an IST stack (M-108).

What W4 added (2026-10-25 to 2026-10-26)

Files, one commit a step (kernel/abi/nt/file.c, user/win/kernel32/):

  1. NtCreateFile with its six dispositions, the directory options and DELETE_ON_CLOSE; the status block says what happened.
  2. NtReadFile and NtWriteFile at an offset, the two special offsets, END_OF_FILE, an event set on completion.
  3. NtQueryInformationFile and NtSetInformationFile: Basic, Standard, Internal, Position, Name, All, Network open, Attribute tag; times, rename, disposition, position, end of file, allocation.
  4. NtQuery(Full)AttributesFile, NtDeleteFile, NtFlushBuffersFile, NtQueryVolumeInformationFile, the cancels.
  5. NtQueryDirectoryFile: six classes, a mask with * and ? kept by the handle, restart, single entry, BUFFER_OVERFLOW for an entry that does not fit.
  6. Names without case on every filesystem under C: - a component the filesystem does not find as written is looked for by folding, mount points included - and names beyond ASCII, UTF-16 to UTF-8 and back.
  7. Share modes, claimed under the file layer's lock before an overwrite truncates anything; NtLockFile and NtUnlockFile on the record-lock table, mandatory as Windows' are.
  8. kernel32's file API over those services, every name with its A form.
  9. The oracle, below, and the kernel32 calls it reached first.

Found on the way: an NtQueryDirectoryFile entry too big for the buffer copied the kernel's stack to the caller (fixed in step 6, before it was pushed). From a source review the same week: vma_change_locked walked on from a descriptor a merge had freed (55f4cf0), and a read-only NT section view could be made writable by NtProtectVirtualMemory (8b45ad2, H-033's class).

Where the oracle stands

At the end of W4 (2026-10-26): ntdll:directory runs to its end, 2707 checks and 70 failures against Wine's 4328 and none - the difference is the checks it skips or cannot reach. ntdll:file stops at QueueUserAPC (W5: APCs); kernel32:file at LocalAlloc, the next kernel32 call not written.

Before W4:

ntdll:om, ntdll:sync and kernel32:sync run and report their failures, and each ends at something W5 provides (threads, timers, keyed events) - see ../abi/windows.md, "What the oracle says at the end of W2". kernel32:virtual runs without a failure until CreateProcessA (W6); ntdll:virtual passes test_NtAllocateVirtualMemory; since W3 step 10 (NtAllocateVirtualMemoryEx and placeholders, 2026-10-22) its Ex test fails one check (write watches) and the address-requirements test ten (allocations below 0x30000000, where the arena has nothing), and it stops in test_NtMapViewOfSection at kernel32's SetFilePointer (W4).

Next checkpoint

  • W5: threads, APCs and timers - what ntdll:file and the sync tests stop at. Left from W4: kernel32:file past LocalAlloc, ntdll:directory's 70 failures, NtLockFile's wait and Key. Left from W3: a low arena below four gigabytes for ZeroBits and address requirements, write watches, RtlCreateUserStack.

Memory Management: Rewrite Plan

Status: Proposed Author of record: written 2026-08-30, after the fourth failed fix of one defect Supersedes: the frame-refcount arrangement described in memory_manager.md

This plan is split across five documents so that each one covers a single concern - the same rule it asks of the code:

DocumentCovers
architecture.mdthe five layers, their data structures and interfaces
observability.mdevery counter and log event, including the ones for subsystems that do not exist yet
maintainability.mdthe structural rules, the layering check that enforces them, and how each phase is verified
phases.mdP0 to P7, specified to the level where implementing them is mechanical
decisions.mdthe risks, the two questions this plan does not settle, and the decisions reserved to the maintainer

1. Why this is a rewrite and not a fix

One defect - a physical frame released while another address space still maps it - has been diagnosed four times and fixed three times, and it is still here:

AttemptWhat was wrongOutcome
Atomic counters(*slot)++ from several cores lost incrementsreal bug, fixed, defect remained
Table coveragethe table's base was the region start, its length the free pagesreal bug, fixed, defect remained
munmapfreed frames without consulting the count at allreal bug, fixed, defect remained
Ownership redesigncount meant "owners beyond the first"; untracked frames answered "free it"real bug, fixed, defect remained

The last attempt is the informative one. The count is now internally consistent

  • when the detector fires, owners_after_put=0, so the arithmetic agrees the last owner let go - and it is still wrong about the world, because some address space maps a frame the count never heard about.

That is not an arithmetic problem. Ownership is inferred, not recorded.

Today the kernel decides "this PTE represents a frame this address space owns" by looking at hardware bits that mean something else:

  • PTE_USER means ring 3 may touch this. A PROT_NONE guard page is owned and has no PTE_USER; counting on it missed 211 frames per boot.
  • PTE_PRESENT means this translation is valid. Splitting a 2 MiB identity leaf leaves 511 present entries describing kernel memory that nothing owns; releasing on it decremented other address spaces' counts.

Both mistakes were made by me, three days apart, for the same reason: the question "does this address space own this frame?" has no answer stored anywhere, so every site invents one.

The second, structural reason: there is no single owner of a mapping. Page tables are written directly in hw_proc_create, hw_map_page, hw_map_low_user_page, hw_aspace_copy_user, hw_aspace_copy_low_user, hw_share_user_leaf, the copy-on-write fault and mprotect; they are torn down in hw_aspace_destroy - with different rules for the two user windows - and in munmap. Adding counters to two of those cannot make the other seven correct.

2. The goal: production ready

Production ready is not "it boots". For a memory manager it is a specific list, and every item on it is something this kernel currently fails:

PropertyWhat it means hereHow it is proved
Correct under concurrencyno frame freed while mapped, no mapping lost, no count wrongdetectors silent across 100 boots, stress service green with printed seeds
Boundedno structure grows without limit; leaks are visible, not fatalframes_leaked zero, frames_free returns to its starting value after a soak
Survives exhaustionrunning out of memory fails the request, never the machinean allocator-exhaustion test completes; no panic, no partial mapping left behind
No partial statesevery failure path unwinds what it didfault injection at each allocation point leaves counts unchanged
Isolatingno process ever sees another's data, or the kernel'sframes zeroed on allocation, poisoned on free, and the poison verified
Predictableno unbounded latency in any path a syscall can reachno synchronous cross-core wait without a bound and a reported timeout
Observablethe state can be inspected on a running machinememinfo, the log ring, and gate assertions on the numbers
Documented and testedinvariants written down; the portable parts tested without bootinghost tests for L0 and L2; a sabotage case per guard

Invariants

These are the statements the implementation must make true, written here so a future change can be checked against them rather than against somebody's memory:

  1. A frame is freed only when its owner count reaches zero.
  2. A frame outside the frame table is never freed.
  3. Every page-table entry describing user memory is written by exactly one function, and carries PTE_OWNED if and only if that address space holds a reference to the frame.
  4. Every frame handed out is zeroed; every frame released is poisoned; the poison is verified when the frame is handed out again.
  5. An allocation failure leaves no mapping, no reference and no counter changed.
  6. No path that a syscall can reach waits unboundedly for another core.
  7. Every counter in §5 is either asserted by the boot gate or documented as not yet meaningful.

Anything that cannot be stated this way is not ready; it is working.

3. Design goals

  1. One owner for physical frames. Nothing outside the frame layer touches the free list or the counts.
  2. One writer of page tables. Every PTE that ever describes user memory is written by one function.
  3. Ownership recorded, never inferred. A dedicated bit in the PTE says "this address space owns this frame"; PTE_USER and PTE_PRESENT go back to meaning only what the hardware says they mean.
  4. Regions described, not walked. mmap, munmap and mprotect operate on descriptors of what was asked for, not on a walk of what happens to be mapped.
  5. Host-testable. The frame layer and the region layer are pure data structures and belong in the portable kernel, where this project's test culture already works. Today the memory code cannot be tested at all except by booting.
  6. Observable by construction. Every layer exports counters and log events from its first commit, including the ones for subsystems that do not exist yet, so adding a page cache later means filling in a number rather than inventing a way to see it.

4. Non-goals

  • Allocator sophistication. The current bump allocator plus free list is adequate; this plan does not introduce buddy allocation, slabs or per-CPU caches. It makes room for them and stops there. Note that predictability is not in this list - an unbounded wait inside a syscall is a correctness problem under §2 and has to go.
  • Changing the two user windows. The low window at 0x400000 exists because Linux binaries link there, and that constraint does not move.
  • Touching the portable kernel/core boot model, beyond giving it real work to do (see §9).

12. Tracking

Progress is tracked as a row per phase in implementation_progress.md and in memory_manager.md, with the counters of §5 as the evidence. A phase is not "done" because its code exists; it is done when its numbers are asserted by the boot gate and its sabotage cases have been seen to fail.

The area is not described as production ready until P7 passes and every invariant in §2 is mapped to a test that has been seen to fail. Until then the status line says what is true: which phases are complete, and which properties are still claims.

Memory Management: Architecture

Part of the memory management rewrite plan: architecture · observability · maintainability · phases · decisions and risks. Five layers, each depending only on the one below it.

  L4  policy          reclaim, LRU, pressure, eviction order
  L3  backing stores  anonymous | page cache (files) | swap
  L2  regions (VMA)   what a process asked for: base, length, prot, backing
  L1  address spaces  the only writer of page tables; map/unmap/protect/clone
  L0  frames          physical page ownership, counts, state, poison

The current code is L1 and L0 tangled together with fragments of L2 implied by brk/mmap cursors, and nothing above.

L0 — Frames

One descriptor per physical frame, in an array indexed by frame number. This is the structure everything later hangs from, so it is worth getting right now rather than growing it under pressure.

typedef struct vibeos_frame {
    uint16_t owners;      /* address spaces mapping it; 0 = free */
    uint8_t  state;       /* FREE | ALLOCATED | RESERVED | PAGE_TABLE | CACHE */
    uint8_t  flags;       /* PINNED | DIRTY | REFERENCED | SWAP_BACKED */
    uint32_t backing;     /* index into the backing store, or 0 */
    uint32_t lru_next;    /* reclaim lists, unused until L4 exists */
    uint32_t lru_prev;
} vibeos_frame_t;         /* 16 bytes */

At 4 KiB frames that is 16 bytes per 4096, or 0.4% of RAM: 1.7 MiB for the 440 MiB this kernel sees. The current byte-per-frame table costs 107 KiB and cannot express any of the above.

state is what makes the two mistakes of §1 impossible: a page-table page and an identity-split entry are not the same thing as a user frame, and the layer knows which it is holding rather than guessing from a hardware bit.

Interface.

uint64_t vibeos_frame_alloc(vibeos_frame_state_t state);
void     vibeos_frame_get(uint64_t phys);        /* one more owner */
int      vibeos_frame_put(uint64_t phys);        /* one fewer; non-zero when freed */
uint32_t vibeos_frame_owners(uint64_t phys);
int      vibeos_frame_reserve(uint64_t base, uint64_t len);

Rules that are the point of the layer:

  • vibeos_frame_put on a frame with no owners is an error, reported, and does not free. The safe direction is a leak.
  • A frame outside the table is never freed. Counted separately.
  • Freed frames are poisoned and the poison verified on reuse, as today.
  • The free list is private. hw_free_page disappears as a public name.

L1 — Address spaces

The only code that writes a page table entry.

int vibeos_vmspace_create(vibeos_vmspace_t *out);
int vibeos_vmspace_destroy(vibeos_vmspace_t *as);
int vibeos_vmspace_map(vibeos_vmspace_t *as, uint64_t va, uint64_t pa,
                       vibeos_prot_t prot, vibeos_map_flags_t flags);
int vibeos_vmspace_unmap(vibeos_vmspace_t *as, uint64_t va);
int vibeos_vmspace_protect(vibeos_vmspace_t *as, uint64_t va, vibeos_prot_t prot);
int vibeos_vmspace_clone_cow(vibeos_vmspace_t *dst, vibeos_vmspace_t *src);
int vibeos_vmspace_fault(vibeos_vmspace_t *as, uint64_t va, uint32_t error);

map takes the frame reference. unmap and destroy release it. Nothing else calls vibeos_frame_get.

The ownership bit. x86-64 leaves bits 9-11 of a PTE available to software. One of them becomes PTE_OWNED: set by vibeos_vmspace_map, never by anything else. Teardown releases exactly the entries carrying it. The identity-split entries do not carry it and are skipped without needing to reason about PTE_USER; a PROT_NONE guard page carries it and is released correctly.

This single bit is what closes the defect in §1. It is listed here rather than applied as a patch because it only works if map is the only writer - which is the rest of L1.

Two windows, one implementation. The low-window special case (splitting the identity map, un-sharing the global tables) becomes an internal detail of vibeos_vmspace_map, chosen by address, rather than a second public function every caller must remember to pick.

TLB coherence belongs here too. map, unmap and protect know whether they widened or narrowed permissions, which is exactly the information needed to decide whether a shootdown is required - the caller does not have to know. The open problem recorded in diagnostics.md - that a synchronous shootdown stalls because syscall masks interrupts - is addressed at the same place, once, instead of at every call site.

L2 — Regions

A process's memory as it asked for it, rather than as it happens to be mapped.

typedef struct vibeos_vma {
    uint64_t base, len;
    vibeos_prot_t prot;
    vibeos_backing_t backing;   /* ANON | FILE | SHARED */
    uint32_t backing_id;        /* file/inode handle, or 0 */
    uint64_t backing_offset;
    struct vibeos_vma *next;
} vibeos_vma_t;

This is what munmap should consult instead of walking page tables and freeing whatever it finds - the walk is the reason munmap was freeing shared frames in the first place. It is also the prerequisite for everything in L3: a fault can only be resolved from a backing store if something remembers what backs the address.

Immediate wins beyond correctness: guard pages become a region with no backing rather than a mapping trick; brk and mmap stop being two cursors that could in principle collide; and /proc/self/maps becomes possible.

L3 — Backing stores

Where a page comes from when it is not there. Three implementations behind one interface:

typedef struct vibeos_backing_ops {
    int (*fault_in)(void *ctx, uint64_t offset, uint64_t *out_phys);
    int (*write_back)(void *ctx, uint64_t offset, uint64_t phys);
    int (*release)(void *ctx, uint64_t offset);
} vibeos_backing_ops_t;
  • Anonymous - allocate a zeroed frame. Exists today, implicitly.
  • Page cache - frames holding file contents, keyed by (file, offset). A fault either finds the page already resident (hit) or reads it from the block layer (miss). This is also what execve should use instead of the 4 MiB staging buffer it copies through today, and what the existing block cache in kernel/fs/ should be merged into rather than duplicated.
  • Swap - frames written to a backing device and reclaimed. Needs L4 to decide which frames, and a swap map to record where each went.

L4 — Policy

Reclaim. LRU lists (the lru_next/lru_prev fields exist from L0 so the lists can be added without touching the frame descriptor again), a watermark, and an eviction order: clean cache pages first, then dirty cache pages written back, then anonymous pages to swap. Pinned frames - page tables, DMA buffers, the kernel's own - are never candidates.

Memory Management: Observability Contract

Part of the memory management rewrite plan: architecture · observability · maintainability · phases · decisions and risks. Every layer exports counters from its first commit, including counters for subsystems that do not exist yet. This is deliberate: the reason the page cache is easy to add later is that the place to report its hit rate already exists, and the reason a regression is caught is that the number was being watched before it moved.

Exposed through one structure, printed by the kernel console's meminfo command and asserted by the boot gate.

CounterLayerMeaningGate assertion
frames_totalL0frames the table describesnon-zero; covers the whole region
frames_freeL0owners == 0non-zero at every stage
frames_allocatedL0in usematches total minus free
frames_leakedL0put refused for lack of an entrymust be zero
frames_double_putL0put on a frame with no ownersmust be zero
poison_hitsL0write to a page after it was freedmust be zero
maps / unmapsL1PTEs created and destroyedmaps ≥ unmaps; converge at teardown
cow_shared / cow_copiedL1pages shared by fork, copies forcedboth non-zero
tlb_shootdowns / tlb_acks / tlb_timeoutsL1cross-core invalidationacks ≥ shootdowns; timeouts zero
faults_resolved / faults_fatalL1page faults by outcomefatal count matches the deliberate ones
vmas_liveL2regions per processreturns to zero when a process exits
cache_hits / cache_missesL3page cachepresent from P0, zero until P4
cache_evictions / cache_writebacksL3
swap_ins / swap_outs / swap_usedL3present from P0, zero until P5
reclaim_scans / reclaim_freedL4present from P0, zero until P6

Log events follow the existing levelled scheme: every allocation, mapping and release is recorded at DEBUG in the ring, printed only at INFO and above. The ring is already 2048 entries and a boot uses about 500.

Memory Management: Maintainability and Verification

Part of the memory management rewrite plan: architecture · observability · maintainability · phases · decisions and risks. This has to be code that either of us can come back to in a month and change safely. That is a design constraint, not a wish, and it is currently failing: the memory manager lives inside a 7800-line file, next to the scheduler, the signal code, the syscall table and two device drivers. Nobody reads that file; people search it. Searching is how the same ownership question got answered three different ways in three different places.

So the rules below are part of the design, and most of them are checkable by a script rather than by review.

Structure

  • One concern per file, none over 800 lines. kernel/mm/frame.c, kernel/mm/vmspace.c, kernel/mm/vma.c, kernel/mm/backing_*.c, kernel/mm/reclaim.c. If a file passes 800 lines it is doing two jobs.
  • A header per layer, stating its contract. The header is where the invariants of §2 live, next to the functions that must uphold them.
  • No layer calls upward. L0 never mentions an address space; L1 never mentions a file or a region; L2 never touches a page table entry.

Enforced, not intended

A CI check (scripts/dev/check-mm-layering.sh) fails the build on:

RuleThe grep
Only the frame layer touches the free listhw_free_page|g_free_pages outside kernel/mm/frame.c
Only the vmspace layer writes page tablesassignments to pt[, pte, pml4[ outside kernel/mm/vmspace.c
Nobody reinvents ownershipPTE_USER or PTE_PRESENT used to decide ownership outside vmspace
No layer calls upwardvibeos_vma_ in frame.c, vibeos_backing_ in vmspace.c, and so on

This is the same discipline as the sabotage cases: a rule nobody can violate by accident is worth more than a rule everybody agrees with. It is also how the two mistakes in §1 would have been caught the moment they were typed.

Readability

  • A reader must be able to answer "who owns this frame?" from one file. That is the single test of whether this rewrite succeeded.
  • Comments say why, and name the failure. The existing convention in this project - a comment that exists because of a specific bug says which bug - carries over. Several of them are the only surviving record of an afternoon.
  • No function over about 60 lines, and no nesting past three levels. The current teardown is four nested loops with two different release rules; that is precisely the shape that hid the defect.
  • Names say the layer: vibeos_frame_*, vibeos_vmspace_*, vibeos_vma_*, vibeos_backing_*. A call site should say which layer it is in without looking anything up.

Testable without a machine

L0 and L2 are pure data structures and go in the portable kernel, where ctest already runs. This matters for maintenance more than for correctness: changing an allocator you can unit-test takes minutes, and changing one you can only test by booting a virtual machine takes an afternoon and a stress run.

The project's existing culture applies unchanged, and this plan leans on it:

  • Every guard has a sabotage case. A check nobody has seen go red is a check nobody should trust. Each phase lists its cases in scripts/dev/cases/.
  • Detectors before features. The free-page poison and the free-while-mapped walk stay, and move into L0 where they belong.
  • Host tests where the code is portable. L0 and L2 are pure data structures. This is the largest single gain: today's memory code can only be tested by booting a machine.
  • The stress service grows with each phase - swap round trips in P5, cache pressure in P4 - and keeps printing its seed.

Memory Management: Phases P0-P7

Part of the memory management rewrite plan: architecture · observability · maintainability · phases · decisions and risks. Each phase below is specified to the level where implementing it is mechanical. Where a choice remains, it is listed in §12 as a decision for you, not for me.

Rules that apply to every phase, without exception:

  • The tree is green at every commit: rc=0, warnings=0, host tests, and both disk gates.
  • No phase begins before the previous one's definition of done is met and demonstrated.
  • No behaviour changes outside the phase's stated scope. If something else is found to be broken, it is recorded in docs/, not fixed in passing.
  • Every guard added gets a sabotage case, and the case is run and seen to turn the boot red before the phase is called done.
  • Documentation is updated in the same commit, per the standing rule.

P0 — The contract, with nothing behind it

Objective. Define every interface and every counter, wire the counters to the existing implementation, and change no behaviour.

Files created

  • include/vibeos/mm.h — the L0 and L1 contracts and the invariants of §2 as comments next to the functions that must uphold them.
  • include/vibeos/mm_stats.h — the counter structure of §6.
  • kernel/mm/stats.c — the counter definitions and one accessor.
  • tests/kernel/mm_stats_tests.c — host tests for the accessor.

Files modified

  • kernel/arch/x86_64/arch_hw.c — existing counters (g_cow_shared, g_cow_copied, g_tlb_*, g_untracked_frees) move into the structure; call sites updated. Nothing else.
  • kernel/core/kmain.c — a meminfo console command printing every counter.
  • scripts/qemu-cli-smoke-linux.py — drive meminfo; assert the must-be-zero counters of §6.
  • CMakeLists.txt, cmake/core_sources.cmake — the new sources.

Interfaces added

typedef struct vibeos_mm_stats {
    uint64_t frames_total, frames_free, frames_allocated;
    uint64_t frames_leaked, frames_double_put, poison_hits;
    uint64_t maps, unmaps, cow_shared, cow_copied;
    uint64_t tlb_shootdowns, tlb_acks, tlb_timeouts;
    uint64_t faults_resolved, faults_fatal;
    uint64_t vmas_live;
    uint64_t cache_hits, cache_misses, cache_evictions, cache_writebacks;
    uint64_t swap_ins, swap_outs, swap_used;
    uint64_t reclaim_scans, reclaim_freed;
} vibeos_mm_stats_t;

const vibeos_mm_stats_t *vibeos_mm_stats(void);
void vibeos_mm_stats_reset(void);          /* host tests only */

Steps, in order

  1. Add the two headers with the structure and the documented invariants.
  2. Add kernel/mm/stats.c and the host test; ctest green.
  3. Replace the existing globals with fields of the structure, one at a time, building after each.
  4. Add meminfo to the console and to the gate's command sequence.
  5. Add the gate assertions for frames_leaked, frames_double_put, poison_hits, tlb_timeouts — all must be zero.

Out of scope. Any change to allocation, mapping or teardown behaviour.

Tests

  • Host: mm_stats_tests.c — accessor returns a stable pointer, reset zeroes.
  • Gate: meminfo_missing, mm_counter_nonzero:<name> for the four above.
  • Sabotage: scripts/dev/cases/mm-stats.txt — remove the meminfo command; make frames_leaked non-zero artificially. Both must turn the boot red.

Rollback. Revert the commit; nothing depends on it yet.

Done when. meminfo prints all counters with plausible values; the four assertions exist and their sabotage cases have been seen red; the boot is byte-for-byte equivalent in behaviour (same phases, same markers).


P1 — The frame layer

Objective. One owner for physical memory. Nothing outside kernel/mm/frame.c touches the free list or a reference count.

Files created

  • kernel/mm/frame.c (~400 lines expected), include/vibeos/frame.h
  • tests/kernel/frame_tests.c

Files modified

  • kernel/arch/x86_64/arch_hw.c — hw_alloc_page, hw_free_page, frame_ref_slot, hw_page_get/put/owners, the poison and the free-while-mapped detector all move out. The names remain as thin wrappers during the phase and are deleted at the end of it.

Interfaces added — as in §5 L0, plus:

int      vibeos_frame_init(uint64_t base, uint64_t len);
uint64_t vibeos_frame_alloc(vibeos_frame_state_t state);
void     vibeos_frame_get(uint64_t phys);
int      vibeos_frame_put(uint64_t phys);
uint32_t vibeos_frame_owners(uint64_t phys);
vibeos_frame_state_t vibeos_frame_state(uint64_t phys);
int      vibeos_frame_reserve(uint64_t base, uint64_t len);

Steps, in order

  1. Write frame.c with the descriptor array of §5, the free list, poison, and the counters from P0. No callers yet. Done (bec59e3).

    One correction to the design landed here: the poison is written on release, never at init. Initialising over a region whose first megabytes are already live would destroy them. VIBEOS_FRAME_WAS_FREED marks the frames the poison check may judge, so a never-freed frame is not reported as corrupt - see 12a1e19.

  2. Host tests first: alloc/free round trip, get/put arithmetic, double-put refused, out-of-range refused, poison verified on reuse, reserve excludes a range, and a never-freed frame full of garbage that must not be flagged. Green before any kernel change. Done; each was confirmed to go red by breaking the code it protects.

  3. Blocked on D9 — the ownership contract of an allocation. The old allocator hands back a frame with zero owners and lets the first mapping make it one; the new layer hands back one owner. Wiring the wrappers without settling that leaks every user page or double-frees every page table, and the mismatch is silent either way. See decisions.md D9; the recommendation is option A. Nothing in step 3 starts before that is answered.

    Then: point hw_alloc_page/hw_free_page at the new layer as one-line wrappers. Boot; meminfo numbers must match P0's.

    Two things step 3 also has to get right, found while reading the allocator:

    • The layer comes up after the bump allocator has already handed out the early page tables and the descriptor table itself. Those frames must be reserved, not free - vibeos_frame_reserve(base, pmm.offset_bytes) covers exactly the prefix a bump allocator has consumed.
    • The old allocator refused any frame above VIBEOS_HW_IDENTITY_LIMIT, since it cannot address one. Reserve that tail rather than letting alloc return a frame nobody can write.
  4. Replace call sites of the wrappers with the real names, file by file. Deferred, deliberately. Steps 3 and 4 could not stay separate: the two contracts for what an allocation returns are incompatible, so the call sites had to change meaning in the same commit that changed where the arithmetic happens. Renaming them as well would have put three kinds of change in one diff, in the subsystem whose whole history is exactly that. The wrappers keep their names and now carry a comment saying so; the rename lands with P2, which touches these call sites anyway.

  5. Delete the wrappers. Add the layering check for g_free_pages. g_free_pages is gone; the wrappers stay until P2 for the reason above.

Status: done (29a6e29), with steps 4 and 5 folded into P2 as recorded. check.sh all green and 8 boots out of 8.

Out of scope. Page tables, address spaces, the two windows. PTE_OWNED is not introduced here.

Added during the phase, and not in the original plan

  • vibeos_frame_alloc_contig, because the bump allocator had to stop serving the callers that needed contiguous memory rather than merely stop being preferred. See decisions.md D9.
  • vibeos_frame_survey, one walk giving the state histogram and the longest free run, so meminfo has a single source and fragmentation is a number.
  • The bump allocator is closed, not just unused. It was still serving the GUI back buffer after the frame layer had adopted the region.

Tests

  • Host: the seven cases in step 2, each with a negative form.
  • Gate: frames_total covers the whole allocator region; frames_free non-zero at USERLAND_START and again at USERLAND_DONE.
  • Sabotage: scripts/dev/cases/mm-frame.txt — make vibeos_frame_put free at a non-zero count; make it accept an out-of-range frame; remove the poison. Three cases, each must turn the boot red.

Rollback. The wrappers are the rollback: revert steps 4-5 and the old implementation is still reachable.

Done when. check-mm-layering.sh reports no free-list access outside frame.c; host tests cover every branch of get/put; 24 boots with frames_leaked and frames_double_put zero.


P2 — Address spaces, and the ownership bit

This is the phase that repairs the defect. Everything before it is preparation and everything after it is new capability.

Files created

  • kernel/mm/vmspace.c (~700 lines expected), include/vibeos/vmspace.h
  • tests/kernel/vmspace_tests.c — with a fake page-table backend, so the walking logic is host-tested even though the hardware format is real.

Files modified

  • kernel/arch/x86_64/arch_hw.c — hw_map_page, hw_map_low_user_page, hw_aspace_create/destroy, hw_aspace_copy_user, hw_aspace_copy_low_user, hw_share_user_leaf, hw_handle_cow_fault, hw_sys_munmap, hw_sys_mprotect are replaced by calls into the new layer.

Interfaces added — as in §5 L1.

The ownership bit. PTE_OWNED = 1ull << 9 (bit 9, software-available on x86-64). Set only by vibeos_vmspace_map. Cleared only by unmap/destroy, which release the reference. The identity-split entries never carry it; a PROT_NONE guard page always does.

Steps, in order

  1. vmspace.c with create, destroy, map, unmap, and the two-window logic folded into map. Host tests against the fake backend.
  2. Introduce PTE_OWNED; map sets it, unmap and destroy release exactly the entries carrying it. Boot. FREE_WHILE_MAPPED must be silent.
  3. Move protect in. Verify the PROT_NONE thread-stack path still works (THREADS.ELF, TFORK.ELF).
  4. Move clone_cow in — this is fork. Verify cow_shared/cow_copied.
  5. Move fault in — this is the copy-on-write resolution and the stale-TLB case. Verify with the stress service.
  6. Move the TLB shootdown decision inside map/unmap/protect.
  7. Delete the old functions. Add the layering check for page-table writes.

Out of scope. Regions, backing stores, reclaim. munmap still walks in this phase — it is rewritten in P3 — but it walks through vmspace_unmap.

Tests

  • Host: map/unmap round trip, ownership bit set and cleared, identity entries never released, clone_cow reference arithmetic, double-unmap refused.
  • Gate: FREE_WHILE_MAPPED absent; maps and unmaps converge at USERLAND_DONE; cow_shared and cow_copied both non-zero.
  • Sabotage: scripts/dev/cases/mm-vmspace.txt — clear PTE_OWNED in map; release entries without it in destroy; skip the reference in clone_cow; drop the shootdown in protect. Four cases, each red.

Rollback. Steps 2-6 are individually revertible; each is a separate commit.

Done when (revised 2026-08-31, and the revision matters more than the criterion). No page-table write outside kernel/mm/; across 48 boots, no FREE_WHILE_MAPPED, no stress-service defect, and frames_leaked, frames_double_put and poison_hits all zero with the frame states summing to the total; the sabotage cases seen red.

The original wording was "48 boots with no failure", full stop. That was wrong, and not by a little: the machine has a pre-existing intermittent boot failure of roughly 8% - see boot_repeatability.md - which makes 48 consecutive clean boots about a 0.02% event. No revision of this project has ever met it, before or after this work, and the nightly gate that applies the same rule to five boots has failed 27 times out of 31 since it was created.

So the criterion was measuring the background rather than the phase. It is now written in terms of what P2 is actually responsible for: the memory manager's own invariants, asserted over a sample large enough to catch a rare violation. Whether the machine boots cleanly every time is a real question and it now has its own file, because it is a different defect with a different history.

The rule this cost two days to learn: check a criterion against the baseline before using it to judge a change. A single 24-boot run of the parent commit would have settled it, and was not taken until it was asked for.

Status: done, against the criterion above. Three concurrency defects found and fixed, all the same shape - two cores disagreeing about a page-table entry - and every one of them settled by experiment, because reading the code reached the wrong answer six times.

The boot rate went from 27/48 while those defects were live to the ~90% that f45ab1d (the commit before P2), a714dbe (step 2) and the finished phase all measure equally. That equality is the evidence that what remains is not P2's.

The three defects

  1. Publication order (e02d9dd). map_raw stored the entry and then took the reference, so a mapping existed that no count knew about. The big one: no STRESS_FAIL has appeared in the ~150 boots since.
  2. Release order, in the fault and in teardown: releasing a frame while the entry still pointed at it.
  3. Double resolution (6510827). Two threads faulting on one page both copied and both released the shared frame. Every store to an entry is a compare-exchange now, so the core that loses discovers it instead of overwriting the winner.

What was learned about the detectors, which is worth as much

The free-while-mapped detector lived in the architecture's free path, and after this rewrite almost nothing frees a frame through there - the address-space layer releases directly. It was watching a door nobody walks through, and its silence was being read as evidence. Moved to the frame layer's last release, it fired immediately.

It then needed three predicates before it was worth trusting: a prediction (owners == 1, taken while other cores map and unmap) reported frames that were never freed; "is it still free" dropped the real cases where another core won the race; more mappings than references is the invariant itself and is indifferent to both. That last one produced mappers=2 owners=1, which is the fact that named the third defect.

Where to pick up the rest

Three defects were found and fixed, all of them the same shape - two cores disagreeing about a page-table entry - and all three confirmed by experiment rather than by reading:

  1. Publication order (e02d9dd). map_raw stored the entry and then took the reference, so a mapping existed that no count knew about. This was the big one: no STRESS_FAIL has appeared in the ~100 boots since.
  2. Release order in the fault and in teardown: releasing a frame while the entry still pointed at it.
  3. Double resolution (6510827). Two threads faulting on one page both copied and both released the shared frame. Every store to an entry is a compare-exchange now, so the loser discovers it.

What is still open. About one boot in twelve, and it no longer looks like the original defect:

  • svc-flap takes a not-present instruction fetch with cr2 equal to rip, inside the same page it was already executing from - a page that vanished under a running process. No detector has caught the moment yet.
  • A wedge, roughly one boot in twenty-four, at varying phases.

Both may predate P2: CLAUDE.md already records an intermittent wedge and a ping hang from before this work. That is the first thing to establish - run scripts/dev/bisect-boot.sh a714dbe 24 (P2 step 2, which measured 16/16) and see whether these two signatures appear there too. If they do, they are not P2's to fix and the phase can close on its own terms.

Tools that now exist for it, all of which earned their place today: repeat-boot.sh keeps the log of every failed boot; the free-while-mapped detector sits on the frame layer's last release and compares mappings against references; vibeos_vmspace_current_op() names the operation in the report; bisect-boot.sh builds a revision and always puts the tree back.

The code is written and the six commits are in (87134d7 through b2d6d7f). Fifteen sabotage cases, all red. No page-table write and no frame reference taken outside kernel/mm/, checked on every build. The host tests, the torture run at 150 seeds, and single boots are all green.

None of that is the phase's own criterion, and the criterion says no. This section stays open until it passes.

What failed, from the three verdicts the run preserved

  • phase=userland_running, wedged, quiet for 45s (boot 44).
  • deliberate_ring3_faults=3_expected=1, unexpected_cpu_fault, stress_run_found_a_defect (boot 47). The stress service found something, which is the detector built for exactly this doing its job.

Twenty-one boots failed and only the last boot's log survived - and it had passed. repeat-boot.sh did not keep the logs of failed boots; it does now. The next run starts with evidence instead of a count.

What this is not. It is not the old premature-free family showing through: FREE_WHILE_MAPPED is silent, the three MUSTBEZERO counters are zero on every boot that reached the console, and the torture run compares against an independent model at 150 seeds without a disagreement. Whatever this is, it was introduced between P2 step 2 - which was 16 boots out of 16 - and step 7. That range is five commits, each individually revertible, which is the reason D4 chose six commits over one.

What the phase cost that the plan did not predict

  • The ownership bit had to move. The plan said bit 9; PTE_COW is 0x200, which is bit 9. See decisions.md D3. A _Static_assert now answers it.
  • vibeos_vmspace_map_raw. Fork installs mappings carrying the copy-on-write mark, and the portable protection type has no word for it. Widening vibeos_prot_t with an x86-64 detail was the wrong trade; the architecture passes the leaf it wants and the layer still adds the ownership bit and takes the reference. It can go when P5 moves copy-on-write in.
  • The memory lock moved into L0. Step 5 wedged the boot: the layer allocates a frame, and until then every allocation had gone through an arch wrapper that took the lock. A layer several cores can drive has to defend itself - "remember to hold the lock" held for exactly one phase.
  • Two behaviours were fixed rather than moved, both in the same family and both reachable from an ordinary program:
    • mprotect used to grant write access to a copy-on-write page, letting a forked process write into a page its parent was still reading.
    • fork selected low-window pages by PTE_USER, so a child of a threaded process inherited an address space with holes where its PROT_NONE thread guards belonged. cow_shared rose from 0xbd2 to 0xc09 per boot when that was fixed - fifty-five pages that had never been cloned.
  • Steps 4 and 5 of P1 folded in here, as recorded at the time. The wrapper names remain; they now wrap one call each.

P3 — Regions

Budget (decision D5). This phase may cost up to 10% of boot time and no more, and the boot gate measures it rather than trusting a reading of the code. Whole-system optimisation is a much later milestone; this number exists only to stop a phase making things quietly worse in the meantime.

Objective. mmap, munmap, mprotect and brk operate on descriptors of what was asked for.

Files created

  • kernel/mm/vma.c, include/vibeos/vma.h, tests/kernel/vma_tests.c

Interfaces added — as in §5 L2, plus insert, remove, find, split, merge, clone.

Steps, in order

  1. vma.c as a pure data structure with host tests: insert, find, split at an address, merge adjacent compatible regions, reject overlaps, clone a list.
  2. Attach a region list to hw_proc_t; populate it from mmap and brk without yet using it for anything.
  3. Rewrite munmap to consult the list rather than walk.
  4. Rewrite mprotect likewise; guard pages become regions with no backing.
  5. clone the list in fork; free it on exit. vmas_live must return to zero.

Out of scope. File backing. Regions carry a backing kind in this phase, and only ANON is implemented.

Tests

  • Host: nine cases covering split, merge, overlap, clone, exhaustion.
  • Gate: vmas_live zero at USERLAND_DONE; ABI self-test unchanged.
  • Sabotage: scripts/dev/cases/mm-vma.txt — skip the split on partial unmap; leak the list on exit; allow an overlapping insert. Three cases, each red.

Done when. munmap does not consult the page tables to decide what to release; vmas_live and vmas_refused return to zero; the host cases pass and their sabotage cases go red.

Status: done. 05545d7 and 8013ee3. Ten host cases rather than nine, five sabotage cases, all red. vmas_live=0 and vmas_refused=0 at the console; check.sh all green.

The boot count is deliberately not part of this, for the reason established at P2 and written up in boot_repeatability.md: the machine has a pre-existing intermittent failure of roughly one boot in eight, so "24 boots green" measures that rather than this phase. The measured rate here is 20/24, which is the background, and the failures carry the background's signatures rather than any region-related one.

What wiring it cost. Three mistakes, all the same shape - the list cleared at the wrong moment - and none of them found by reading:

  • exec cleared the new list immediately after copying it, discarding the regions the loader had just built.
  • The loader cleared the list after mapping the image rather than before. The struct is reused across execs so it has to be emptied, but emptying it late throws away the description of the program about to run.
  • Both surfaced three layers away, as the dynamic loader reporting "RELRO protection failed" - mprotect refusing the read-only remap a C library performs on its own image during startup. What closed it in one boot was making the refusal print the address it was refusing; it now does so permanently.

One design note worth keeping: the ELF image is recorded page by page and left to merge, not as one span. A segment has holes between it and the next, and describing those as mapped would let mprotect accept an address the ABI self-test exists to make it refuse.


P4 — Page cache

Objective. A fault can be resolved from a file, and the same file read twice is read from disk once.

Files created

  • kernel/mm/backing_anon.c, kernel/mm/backing_file.c, include/vibeos/backing.h, tests/kernel/backing_tests.c

Steps, in order

  1. The vibeos_backing_ops_t interface and the anonymous implementation, which is what P3's regions already do implicitly.
  2. The file implementation with a hash keyed on (file id, offset).
  3. execve and the ELF loader fault pages in instead of copying through the 4 MiB staging buffer.
  4. Merge kernel/fs/'s block cache into this one rather than running both.

Out of scope. Writable file mappings and write-back. MAP_SHARED is refused with ENOSYS in this phase and implemented in a later one.

Tests

  • Host: hit, miss, eviction, key collision.
  • Gate: cache_hits and cache_misses both non-zero; running BusyBox twice shows the second exec hitting; the staging buffers are gone from meminfo.
  • Sabotage: scripts/dev/cases/mm-cache.txt — never record a hit; return the wrong page for a key; skip eviction. Three cases, each red.

Done when. The hit ratio on a normal boot is asserted, not merely non-zero.

Status: steps 1 and 2 done (kernel/mm/backing.c). The interface, the anonymous implementation and the page cache exist, are host-tested in eight groups, and have five sabotage cases, all red. The cache is wired to the exec path: a boot reads about eleven megabytes of programs, and 1041 of those pages now come from memory against 1820 from disk - a 36% hit ratio, which the boot gate asserts as a ratio.

The first table size was 768 pages and gave five hits in an entire boot: every program evicted the last one. "Non-zero" would have passed that happily, which is why the assertion is a ratio and not a presence check.

Step 3 is blocked on the loader rewrite - see docs/exec/, phase X-P2, which is where it will actually land. It cannot be done here: faulting pages in means execve stops copying out of a staging buffer, and that is a rewrite of the exec path rather than a change to the cache.

Step 3 is not done, and the phase is not finished. execve still reads a whole program into the 4 MiB staging buffer and copies out of it; faulting pages in instead is a substantial rewrite of the exec path and is the remaining work here. The staging buffers therefore still exist, which was part of the original "done when" - that line has been narrowed to what is actually met rather than quietly treated as satisfied.

Step 4 is out of scope by decision D6: the filesystem block cache stays where it is. Decided before the phase began, with the reasoning in decisions.md.


P5 — Swap

Objective. A workload larger than physical memory completes.

Files created

  • kernel/mm/backing_swap.c, kernel/mm/swapmap.c, tests/kernel/swap_tests.c

Steps, in order

  1. A swap map over a block device: allocate, free, read, write a slot.
  2. Page-out: pick a frame (P6 chooses well; here it is FIFO), write it, record the slot in the region, unmap it.
  3. Page-in on fault.
  4. A stress operation that allocates beyond memory and verifies every byte, with the seed printed as usual.

Correction, 2026-08-31: this phase depends on P6 and cannot run before it.

Step 2 above says "record the slot in the region", which assumes a frame belongs to one region. After a fork it belongs to several, in different address spaces, and evicting it means unmapping it from all of them - which needs the reverse map from frame to mappings that P6 introduces for compaction.

So P5 either restricts itself to frames with a single mapper, which is a swap that cannot evict the pages a forking workload actually accumulates, or it waits for that map. It waits. The order is P6 then P5, and P6 step 1 grows the reverse map as its first item rather than its last.

Out of scope. Choosing which page to evict beyond FIFO — that is P6.

Tests

  • Host: swap map allocation, exhaustion, round-trip fidelity.
  • Gate: swap_ins and swap_outs non-zero; the new stress operation green.
  • Sabotage: scripts/dev/cases/mm-swap.txt — corrupt one byte on write-back; lose the slot on page-out; double-free a slot. Three cases, each red.

Done when. A program needing more than free memory completes; data through swap is byte-identical; 24 boots green.


P6 — Reclaim policy

Objective. Pressure is survivable and the eviction order is deliberate.

Files created — kernel/mm/reclaim.c, tests/kernel/reclaim_tests.c

Steps, in order

  1. LRU lists using the fields reserved in the frame descriptor at P1.

  2. Watermarks: a low mark that starts reclaim, a minimum that blocks allocation.

  3. Eviction order: clean cache, then dirty cache written back, then anonymous to swap. Pinned frames never.

  4. Pinning for page tables, DMA buffers and kernel allocations.

  5. Compaction. Reclaiming free memory is not the same problem as making it usable: vibeos_frame_alloc_contig is first fit, so after a few hours of allocation and release there can be plenty free and no run of it long enough for a 4 MiB staging buffer or a DMA descriptor. Free frames are recovered by P6 steps 1-3; contiguous space is only recovered by moving frames that are still in use and repointing whatever maps them.

    Deliberately last, and it cannot move earlier. Moving a frame means finding every page-table entry that points at it, and that is only answerable once ownership is recorded rather than inferred - which is what L1 and L2 build at P2 and P3, and precisely what today's kernel cannot do. Attempting it before then would mean scanning every address space on every move and trusting the result, which is the same "reconstruct the truth from the hardware bits" mistake this rewrite exists to end.

    The order is: a reverse map from frame to the mappings that hold it (the backing field in the descriptor is reserved for its handle), then a move that copies the frame, updates every mapping, shoots down the TLB, and only then releases the old frame. Pinned frames - page tables, DMA, anything a device has an address for - are never moved, which is the same list as step 4.

    Measured, not asserted by eye: frag_largest_run and frag_free_frames in the stats, so "there is memory but not in one piece" is a number somebody can read in meminfo before it becomes an allocation failure nobody can explain.

Tests

  • Host: list ordering, watermark transitions, pinned frames skipped; a compaction that moves a frame, with every mapping following it, and a pinned frame refusing to move.
  • Gate: reclaim_scans/reclaim_freed non-zero under the pressure test; the order asserted by which counter moves first.
  • Sabotage: scripts/dev/cases/mm-reclaim.txt — evict a pinned frame; evict dirty before clean; never write back; compact a frame and skip one of its mappings; compact a pinned frame. Five cases, each red.

Done when. The exhaustion workload from P7 completes through reclaim rather than failing; the order is asserted; and a run that fragments memory deliberately can still satisfy a 4 MiB contiguous request afterwards, which is the only honest test of compaction.


P7 — Acceptance: the properties of §2, demonstrated

The phases above build the thing; this one proves it is ready, and it is not optional. Each item is a test that did not exist before.

  • Exhaustion. A service that allocates until vibeos_frame_alloc fails, then exits cleanly. The machine must survive, the counters must return to their starting values, and nothing may panic.
  • Fault injection. A build-time switch that fails the nth allocation, run across a sweep of n. Every failure must leave counts unchanged and no mapping behind - this is how invariant 5 is proved rather than asserted.
  • Soak. A long run - the stress service with its round count raised by two orders of magnitude - after which frames_free matches its value at USERLAND_START. A leak of one frame per fork is invisible in 120 rounds and obvious in 12000.
  • Isolation. A test that allocates, writes a pattern, frees, reallocates, and asserts it never reads the previous tenant's data or the kernel's.
  • Latency. No path reachable from a syscall waits for another core without a bound; the bound is asserted and its timeout counter must be zero.

Done when: all five run in CI, each has a sabotage case that turns it red, and the invariants of §2 are each mapped to the test that proves it.


Where the plan stands

Written after P5, P6 and most of P7 landed in one stretch. Each phase has its own detail file under docs/implementation_progress/; this is the summary that says what is not done, because that is the part a status table tends to lose.

phasestate
P0–P3done
P4steps 1–2 done; image pages are mapped from the cache (95% of them, 74% hit ratio, both gated as ratios). Step 3 not done: execve still reads whole programs into the 4 MiB staging buffer, so the buffers are still there.
P5map, page-out, page-in and the anonymous tier built, tested and sabotage-verified against a memory-backed device. No swap area exists on the boot media, so no anonymous source is registered and page-out is never called on the real machine. skipped_no_swap counts what that costs.
P6done. Reverse map audited on every fork, watermarks and admission wired into the allocator, page tables pinned, compaction with the fragmentation test the plan calls the only honest one.
P7three properties of five: exhaustion, isolation, fault injection. Soak and latency are not proved and are named as such.

The two things most worth doing next

Give swap somewhere to write. Everything above the device is built and tested; what is missing is carving and sizing an area on the block device. Until then P5 is a subsystem that has never run on the real machine, which is the state this project has learned to distrust - "the driver nobody runs is the one that ships broken".

The 20 MiB pressure defect. svc-press allocates 256 KiB at a time and touches every page; at eighty blocks - twenty megabytes on a guest with four hundred - the machine stops answering and the serial log fills with binary. That is not exhaustion and not the watermarks refusing anything. It is a defect the boot has always had that nothing had ever asked it for enough pages in a row to find, and it is the reason svc-press is built but not started by init.

One defect still open from before all this

A frame released while an address space still maps it, seen as mappers=2 owners=1 at a destroy and as a page of zeroes handed to a live process. Fork is now excluded by measurement - fork_undercounted reads zero across every boot - so the remaining candidates are munmap, the copy-on-write resolution and teardown. Three detectors watch for it every boot and the gate fails on any of them.

P4 step 3 — closed

The exec and interpreter staging windows are 64 KiB each, down from 4 MiB and 2 MiB. The blocker was not in this plan and not in this subsystem: the frame layer's poison detector was reading uninitialised descriptor flags and reporting 3019 use-after-frees per boot, and the staging sizes only decided where that descriptor table landed in memory. See mm_frame_init_flags.md.

P7 — soak, closed

The frame loss does not grow with the work: 26 at 120 stress rounds, 28 at 12000, twice. The round count is a build knob and scripts/dev/soak.sh runs the comparison, because one measurement cannot tell a fixed cost from a per-round leak.

What it took was fixing the machine rather than the counter. The wait for userland to finish treated a blocked task as retired, so on a long run the accounting was sampled while the stress service was still forking - and the mid-flight numbers looked like a leak that scaled. The gate now asserts the premise the accounting depends on. See mm_soak.md.

Latency remains, with a defect already named: VIBEOS_BLK_TIMEOUT is defined, printed by kmain and asserted by the boot gate, and produced by no driver - every timeout collapses into a generic -1. The assertion is green and cannot go red. virtio-net's g_tx_timeouts is the same shape: incremented, read by nobody.

P7 — latency, closed. P7 is done.

Every wait reachable from a syscall has a bound; each bound now increments a counter; the counters are reported in one bracketed line and the boot gate asserts they are zero, treating a missing line as a failure.

The bounds were already there. What was missing was any way to tell one had fired: both disk drivers returned the same -1 for a bound firing as for the device's own error bit, so VIBEOS_BLK_TIMEOUT - defined, printed and asserted by the gate - was produced by nobody, and virtio-net's transmit timeouts were counted into a static nothing read. See mm_latency.md.

All five properties of P7 now hold. What is left in this plan is not a phase: it is that P5 has never run on the real machine, because the boot medium carries no swap area. The next step is a contiguous file on the existing FAT medium, then a partition.

P5 — swap has somewhere to write

An 8 MiB contiguous file on the boot volume, 2048 slots. The file is staged by scripts/make-swapfile.py; vibeos_x86_64_fat_file_extent resolves it to sectors and reports whether the chain is one run, and a fragmented file is refused rather than written through.

Nothing in kernel/mm/ had to change. What was missing was every connection to it: the vmspace backend's two swap hooks were null, the fault handler did not know what a swapped entry was, and no code anywhere decided which page should go - so vibeos_reclaim_set_anon_source was called by nobody. kernel/mm/anon.c is that caller.

The boot check has two halves and only the second one earns its keep: writing a page through the area and reading it back would bless an area pointed anywhere, because a write and a read that use the same wrong address agree perfectly. The bytes are therefore looked for at the front of SWAPFILE.BIN through the filesystem, which resolves the file's own chain. Shifting the area by eight sectors passes the first half and fails the second.

The remaining gap is named rather than glossed: freed_anon is zero on every boot, because nothing runs this machine short of memory. The area is real, it points at the file, and a page survives the trip to the disk and back

  • all gated. The eviction path under pressure is not yet proved. See mm_swap_file.md.

Memory Management: Decisions, Risks and Open Questions

Part of the memory management rewrite plan: architecture · observability · maintainability · phases · decisions and risks.

RiskMitigation
A rewrite mid-flight breaks a working systemPhases are shippable and ordered so P2 - the repair - lands before any new capability
arch_hw.c is 7800 lines and the memory code is threaded through itP1 and P2 extract rather than rewrite in place; the wrappers keep call sites compiling while they are moved
The low window's identity split is subtle and easy to get wrong twiceIt is the first thing PTE_OWNED removes the guesswork from, and it gets its own sabotage case
The frame table grows from 107 KiB to 1.7 MiB0.4% of RAM, and it is what makes L3 and L4 possible at all
Scope creep into an allocator rewriteExplicit non-goal in §3
  1. Where does the portable kernel fit? kernel/core/ has a memory model that is host-tested and initialised after userland has already run, so it has no runtime role. L0 and L2 are exactly the sort of thing it should own. This plan puts them in kernel/mm/ for now; whether that is kernel/core/ is a decision for P1.
  2. Does the TLB shootdown design survive contact with L1? The known problem is that a synchronous barrier cannot be met while syscall masks interrupts for its whole duration. L1 concentrates the question in one place; it does not answer it. The honest fix is likely to stop masking interrupts for the whole syscall, which is a scheduler change, not a memory one.

These are the points where I would otherwise choose on your behalf. I will stop and ask at each one rather than pick.

#DecisionWhy it is not mineWhen it is needed
D1Where does the new code live?Decided 2026-08-30: kernel/mm/, linked into both the kernel image and the host test binary. See below.—
D2How large is the frame descriptor?Decided 2026-08-30: 16 bytes, the complete form. See below.—
D7Which device backs swap, and is it configured or discovered?A product decision about how VibeOS is deployedBefore P5
D8What happens when swap is full — kill the allocating process, or fail the allocation?A policy question with no technically correct answerBefore P5

Anything not on this list and not specified above, I will implement as written. If the plan turns out to be wrong about something, I stop and say so rather than improvise a different design.

Decisions taken

D1 — kernel/mm/, decided 2026-08-30

A dedicated module, compiled into the kernel image and into the host test binary. The name says what it contains and it inherits nothing.

The alternative was kernel/core/, which would have given the portable kernel the runtime job it has never had - and that argument got stronger this week, because vibeos_kmain now runs before userland rather than after it. It was rejected because kernel/core/ already holds the boot state, the log, the timer and the interrupt controller; adding memory to it turns a module into a container.

This does not close the "two kernels" question recorded in the plan. It is noted there as still open, deliberately, rather than being quietly answered by where a file was put.

D2 — 16 bytes per frame, decided 2026-08-30

The complete descriptor: owners, state, flags, backing, lru_next, lru_prev. 1.7 MiB against the 440 MiB this kernel sees, or 0.4%.

The smaller forms - 8 bytes without the reclaim lists, 4 bytes with only what the repair needs - were rejected for the same reason: each buys back well under a megabyte and costs a second migration of the structure and its tests, at P4 or P6, in a subsystem whose whole problem has been changing the same thing four times. The point of this rewrite is not to do that again.

D9 — the ownership contract of an allocation

Decided A, 2026-08-30. Landed in 29a6e29. What follows is the question as it was put, kept because the reasoning is the record of why the kernel counts the way it does.

It surfaced while writing the wrappers, and it is not a detail: today hw_alloc_page hands back a frame with a reference count of zero, and the count only becomes one when hw_map_page maps it. The new layer hands back a frame with one owner, because "allocated and owned by nobody" is exactly the state that made the old scheme unsafe - it is indistinguishable from free.

Both are coherent; they are not compatible, and the mismatch is silent in the worst way. Wire the wrappers without settling it and every user page ends up with two owners instead of one (allocation plus first mapping), so nothing is ever reclaimed - a leak. Settle it the other way carelessly and a kernel page table, which is allocated and freed without ever being mapped, is freed at a count that was never incremented - a double free.

Option A — an allocation gives the caller one owner (Linux's model)

alloc returns owners = 1. The caller holds that reference. Mapping the frame takes another; unmapping drops it. A caller that allocates a frame purely to map it drops its own reference once the mapping exists, exactly as alloc_page/put_page do.

  • Correct for kernel page tables with no change: allocate, use, put, freed.
  • Every path that allocates a frame for a mapping gains one put. There are about nine such sites.
  • A missed put leaks one frame and shows up in meminfo. A leak is the failure direction this whole rewrite chose on purpose.

Option B — an allocation gives the caller nothing, mapping owns the frame

alloc returns owners = 0, as today. Only mappings count.

  • No call site changes at all.
  • Keeps the state that has caused every defect in this subsystem: a frame that is allocated and owned by nobody reads as free to anything that asks, which is precisely what the invariant "a frame is freed only at zero" was written to make impossible.
  • Kernel page tables live permanently at zero owners, so the count cannot be used to check anything about them.

My recommendation is A, and it is not close. B is cheaper this week and re-creates the exact ambiguity that cost four fixes; A costs nine put calls and makes every wrong one a measurable leak rather than a corruption that surfaces in an unrelated program three boots later.

What I need from you is a yes to A, or a reason for B. Until then P1 stops at step 2, which is where it is: the layer is built, host-tested and compiled in, and nothing calls it yet.

D9 as implemented

An allocation returns a frame with one owner, and that owner is the caller.

  • A caller that allocates in order to map - process images, stacks, mmap, brk, the copy-on-write copy - hands the frame to the address space and releases its own reference immediately after the mapping succeeds. Nine sites, each carrying a comment naming this decision.
  • A caller that allocates memory for the kernel itself - page tables, the PML4, kernel stacks - never maps it, so its one reference is the only one and hw_free_page releases it.
  • Releasing the last reference and reclaiming the frame are now one operation. There is no longer any moment in which a frame has no owners and is not yet free, which is the state every defect in this subsystem has lived in.

Three consequences worth writing down, because each was found by doing it rather than by planning it:

  1. Contiguous allocation had to exist before any of this could be wired in. The bump allocator underneath served the exec staging buffers and the two-page kernel stacks, and it has to stop serving anything at all: while two allocators hand out frames from one region, one of them is wrong about what is free. vibeos_frame_alloc_contig covers those callers.

  2. A second allocator was already live and invisible. The GUI back buffer - several megabytes - was taken from the bump allocator after the frame layer had adopted the region. The frame table still described those frames as free and handed them to a process; the desktop then rendered over its memory. No detector fired, because nothing was freed early. The bump allocator is closed the moment the layer comes up, so the next such call fails visibly.

  3. meminfo was not one picture. It derived allocated as total minus free, which swallowed every reserved frame, and took reserved from somewhere else again. One walk of the frame table answers it now and the boot gate fails if the states do not sum to the total.

D3 — a bit in the entry, decided 2026-08-30 (and the bit was wrong)

A software bit in the page-table entry, not a side table. VIBEOS_PTE_OWNED.

The argument for a side table was that a future use of the bit would break it. The argument against, which won, is that a side table is a second structure that has to agree with the first - and this subsystem's entire history is two structures disagreeing about who owns a frame. The whole point of the bit is that the record and the thing it describes cannot drift apart, because they are the same word of memory.

The plan named bit 9 and bit 9 was not free. PTE_COW in the x86-64 code is 0x200, which is bit 9. Sharing them makes every mapped page read as copy-on-write, so a write fault on a genuinely read-only page would be resolved by granting the write instead of killing the process - a program could write to its own text segment and be told it worked.

Sixteen clean boots showed nothing, because the two bits only disagree on a path that needs a fault on a page a program is not allowed to write. It was caught by reading the definition while wiring up protect, which is luck, not method. What replaces the luck is a _Static_assert that the two bits are different, confirmed to fire by putting them back together.

Ownership is bit 11. The risk the side-table argument raised is real and is now answered by the compiler rather than by care.

D4 — seven commits, decided 2026-08-30

Landed as six, and the phase's own history is the argument for it. Each step was verified before the next began, and two of them failed in ways that would have been far harder to attribute inside one large commit:

  • Step 2 wedged nothing but hid the bit collision above.
  • Step 5 wedged the boot outright, and the cause was not the code the step was about - it was that moving allocation into the layer moved it out from under the memory lock the architecture had been taking at the call sites.

Both were found by having a green commit immediately before them to compare against. A single commit for the phase would have presented both at once, in a diff of about a thousand lines, with a silent wedge as the only symptom.

D5 — ten per cent, and measured, decided 2026-08-30

The boot may get up to 10% slower per phase, and the boot gate times it and fails past that.

The measurement is the decision, more than the number is. A budget nobody checks is not a budget, and a slowdown noticed three phases later cannot be attributed to the phase that caused it - which in this subsystem means it gets attributed to whichever phase somebody happens to be reading. The gate records the elapsed time to BOOT_OK and compares it against a recorded baseline.

The user's framing, which is the right one: optimising the system as a whole is its own milestone, a long way out. This budget exists to stop a phase making things quietly worse in the meantime, not to make anything fast.

D6 — two caches, for now, decided 2026-08-30

P4 builds a page cache for memory. The block cache in kernel/fs/ stays where it is and keeps doing what it does.

They hold different things keyed differently - one by sector, one by offset within a file - and merging them pulls the filesystem into a plan that does not otherwise touch it. The argument that settled it is narrower than tidiness: this subsystem's every serious defect has been two structures disagreeing about one resource, and creating that situation on purpose, mid-rewrite, to save a layer of indirection is the wrong week for it.

The question is worth reopening once P4 exists and the duplication can be measured rather than guessed at. It is recorded here so that reopening it is a decision rather than a rediscovery.

Tasks and Scheduling: Rewrite Plan

The same treatment the memory manager got, for the subsystem that has produced this project's most expensive defects. Written to the same rules: the goal stated as properties that can be proved, invariants written down before code, layers that can be host-tested, and a decision list I do not settle on my own.

Companion documents: architecture.md, phases.md, decisions.md.

1. Why this is a rewrite and not a fix

Everything about a task lives in kernel/arch/x86_64/arch_hw.c, which is 8312 lines and 267 static functions holding the scheduler, the task table, syscalls, program loading, pipes, signals, the network glue, the interrupt controllers and the serial console. There are no boundaries inside it, so any function can reach any state, and several have.

That is not a stylistic complaint. It is the direct cause of the defects this subsystem has produced, and they share one shape: a task's lifetime is managed by several pieces of code that each know part of the truth.

  • The silent wedge, roughly one boot in three, was hw_task_exit publishing a dying task as ZOMBIE before tearing its address space down. A parent reaped the slot, a fork on another core took it, and the late teardown freed the new tenant's page tables. No panic, no output - the machine simply stopped.
  • The same shape in the reaper: a slot published FREE while the task was still being written to.
  • execve destroyed an address space without asking whether sibling threads still ran on it, while hw_task_exit had asked that question for months. The two live two thousand lines apart with no interface between them, so the rule existed in one and not the other.
  • A slot recycled with a stale cr3 installed page tables belonging to somebody else. The fix was to clear four fields in the allocator and hope the list is complete.

Each was fixed where it was found. None of them could have been prevented, because there is nothing that says what a task slot's states are or who may change them.

2. The goal: production ready

PropertyWhat it means hereHow it is proved
One owner of a task's lifetimea slot changes state in one place, through one function per transitiona state-machine table; every transition asserted; no direct writes to state outside it
No use after publishnothing writes to a task after publishing it as reusable, and nothing reads one after it is freetenancy counter checked on every access; a sabotage case per publish point
Correct under concurrencya slot recycled on one core is never half-initialised on anotherhost tests over the state machine; the boot gate asserts no task ever runs with a cr3 it did not set
Address spaces outlive their userstables are freed when the last task using them stops, and not beforeone shared test asked by address space, used by exit and exec alike
Survives exhaustionno free slot fails the request, never the machinea fork-storm test; refusals counted
Predictablepicking the next task is bounded and does not hold a lock across workno allocation, no I/O and no page-table walk under g_sched_lock
Observablea running machine can say what every task is and how it got theretasks on the console, per-task provenance fields, gate assertions
Host-testablethe state machine and the run queue are portable Ctests/kernel/sched_tests.c, no virtual machine needed

Invariants

  1. A task slot is in exactly one state, and every change of state goes through one function that records who made it.
  2. A slot is published as reusable last: after every field it owns has been released and cleared.
  3. A task never runs on an address space that another task has released.
  4. An address space is destroyed by whoever holds the last reference to it, and that question is asked in exactly one place.
  5. Nothing under the scheduler lock allocates, frees, reads a disk or walks a page table.
  6. Every field of a recycled slot is either reset by the allocator or provably written before the task runs. There is no third category.
  7. A task's cr3 names tables that exist for as long as the task can be scheduled.

3. Design goals

  1. The state machine is data, not control flow. A table of legal transitions, checked, rather than assignments spread across nine functions.
  2. Tenancy, not slot index. A slot plus a generation number identifies a task; a stale index is then detectable rather than silently valid.
  3. Address-space ownership is counted, not inferred. The same lesson as the memory manager's PTE_OWNED: exit and exec ask one shared function instead of each reasoning about threads.
  4. The run queue is separable. Picking the next task is a data-structure question and belongs in portable C where it can be tested.
  5. Policy comes last, and only after accounting. Priorities, fairness, affinity and load balancing are the point of a scheduler and they are phases S-P4 and S-P5, not omissions - the first draft of this plan listed them as non-goals in one line, which was wrong. A policy chooses between correct options, so it needs a correct lifetime layer underneath it; and fairness cannot be built before something measures CPU time, which nothing here does today.
  6. Observable by construction. Provenance fields (cr3_set_by, aspace_killed_by, ready_by) already exist because they were each added after a bug; they become part of the design instead of scar tissue.

4. Non-goals

  • Preemption model changes at the hardware level. Timer-driven preemption stays as it is; what changes at S-P5 is the quantum being a named number rather than whatever the timer period happens to be.
  • SMP bring-up, the APIC, or the interrupt path, except where they read task state.
  • The syscall dispatch table, which is its own subsystem and its own plan.

5. Tracking

A macro-area row in docs/implementation_progress.md, a detail file under docs/implementation_progress/, and scripts/dev/make-book-summary.py re-run, as the project rule requires.

Tasks and Scheduling: Architecture

Three layers, the same shape as the memory manager's: the portable ones can be host-tested, and the architecture layer is what is left when everything that is not machine-specific has been taken out of it.

  S2  lifetime      exit, reap, exec, fork/clone; who owns an address space
  S1  run queue     which task runs next, per CPU
  S0  task table    slots, states, tenancy; the state machine

Today all three are one file with the interrupt controllers and the console.

S0 — The task table

Slots, their states, and the one function that changes them.

typedef enum {
    VIBEOS_TASK_FREE = 0,   /* nobody's; may be allocated                  */
    VIBEOS_TASK_SETUP,      /* allocated, being filled in; not schedulable */
    VIBEOS_TASK_READY,      /* schedulable                                 */
    VIBEOS_TASK_RUNNING,    /* on a CPU                                    */
    VIBEOS_TASK_BLOCKED,    /* waiting on something                        */
    VIBEOS_TASK_ZOMBIE,     /* dead, not yet reaped                        */
    VIBEOS_TASK_STATE_COUNT
} vibeos_task_state_t;

int  vibeos_task_transition(uint32_t slot, vibeos_task_state_t to,
                            const char *why);

The transition table is the design. A two-dimensional array of legal moves, checked on every change, with the name of the caller recorded. Today a state is a field several functions assign to, and the illegal transitions are the ones that produced the wedge: RUNNING to FREE without passing through ZOMBIE, or ZOMBIE to FREE while the slot was still being written.

Tenancy. A slot carries a generation number that increases every time it is allocated. A reference to a task is the pair (slot, generation), so a stale reference is detectable instead of silently naming whoever holds the slot now. hw_task_t already has alloc_seq for this - it was added after a bug and is checked in two places out of the dozen that need it.

Publish last. The rule the reaper and exit each learned separately: every field a slot owns is released and cleared before the slot becomes VIBEOS_TASK_FREE. Making it the last statement of one function is the whole mechanism.

S1 — The run queue

Which task runs next on this CPU. A data structure, and therefore portable:

void vibeos_runq_init(vibeos_runq_t *q, uint32_t cpus);
void vibeos_runq_add(vibeos_runq_t *q, uint32_t cpu, uint32_t slot);
void vibeos_runq_remove(vibeos_runq_t *q, uint32_t slot);
int  vibeos_runq_pick(vibeos_runq_t *q, uint32_t cpu);   /* slot, or -1 */

Round-robin over ready tasks, with a per-CPU idle task as the answer when there are none - which is what the current hw_pick_next does, extracted so it can be tested without a machine. The value of moving it is not the algorithm; it is that "two CPUs picked the same task", a defect this project has already had, is a unit test rather than a boot.

Nothing under the lock. The scheduler lock protects the queue and the state field, and nothing else may happen while it is held: no allocation, no page table walk, no disk. hw_spin_lock masks interrupts, so anything slow under it is slow with the timer off - the same rule the FAT read learned the hard way.

S2 — Lifetime

The transitions that mean something: fork, clone, exec, exit, reap. This is where the address-space question lives.

int vibeos_task_exit(uint32_t slot, int status);
int vibeos_task_reap(uint32_t parent, uint32_t child, int *out_status);

One question about address spaces, asked in one place. execve and hw_task_exit both need to know "is anybody else running on these tables?" and for months only one of them asked. hw_aspace_shared_by_other now exists and both use it; the layer boundary is what stops a third caller appearing that does not.

Ordering, which is the whole subsystem. Two rules, both learned from failures documented in CLAUDE.md:

  • Tear down before announcing. A dying task's address space is destroyed before the slot is published as ZOMBIE, because a parent may reap the instant it sees one.
  • Take what you need first, publish last. Anything the kernel reads out of a task must be read before the slot becomes reusable.

These are stated here so a future change can be checked against them rather than against somebody's memory of why the machine used to stop without a word.

What stays in the architecture layer

Context switching, CR3 loading, the per-CPU area, the TSS and the kernel stacks, the guard in hw_task_load_cpu_state. Those are machine-specific and belong where they are - the point of the split is that they stop being mixed with the question of what a task is.

Tasks and Scheduling: Phases

Same rules as the memory manager's phases: each one lands with host tests and sabotage cases before the next begins, each names what is out of scope, and each says what "done" means in terms the phase itself controls.

Criteria are checked against the baseline before they are used to judge anything. That rule is here because its absence cost two days on the memory manager: a "48 boots with no failure" criterion was written for P2 and could never have been met by any revision, because the machine has a pre-existing intermittent failure of roughly one boot in eight. See boot_repeatability.md. Every phase below is judged on its own invariants, and any boot count is a ratio against the same measurement taken on the parent commit.


S-P0 — Counters and the console view

Objective. Before anything moves, a running machine can say what its tasks are. The memory manager did this first and it paid for itself immediately.

Files created — include/vibeos/task_stats.h, kernel/sched/stats.c

Steps

  1. A stats structure: tasks created, exited, reaped, slots refused, illegal transitions attempted, tenancy mismatches caught.
  2. A tasks console command: slot, generation, state, pid, tgid, cr3, and the provenance fields that already exist (cr3_set_by, ready_by, aspace_killed_by).
  3. Gate assertions on the counters that must be zero.

Done when. tasks prints the table on a running machine; the must-be-zero counters are asserted by the boot gate.

Status: done (5d696fa). The command prints every slot with its generation, state, pid, tgid, ppid, cr3 and the three provenance fields; the gate drives it and asserts four counters at zero.


S-P1 — The task table and the state machine

Objective. A slot changes state in one place, and an illegal change is caught rather than performed.

Files created — include/vibeos/task.h, kernel/sched/task.c, tests/kernel/task_tests.c

Steps, in order

  1. The state enum, the transition table, and vibeos_task_transition. Host tests first: every legal move, every illegal one refused and counted, and the generation increasing on each allocation.
  2. Allocation and release as the only writers of state, with the release publishing FREE as its last statement.
  3. Point the architecture's g_tasks[i].state assignments at it, one call site at a time.
  4. Delete the direct assignments. Add a layering check: no write to .state outside kernel/sched/.

Out of scope. The run queue, exit, exec.

Tests

  • Host: the transition table exhaustively, tenancy, publish-last ordering.
  • Sabotage: scripts/dev/cases/sched-task.txt - allow RUNNING to FREE; publish the slot before clearing it; reuse a slot without bumping the generation.

Done when. No .state assignment outside the layer; illegal transitions counted and zero on a normal boot; the sabotage cases red.

Status: done (142e3fc). All twenty-six assignments go through one function; eleven host cases and five sabotage cases, all red.

The table was wrong on its first boot and the machine said so. SETUP to RUNNING was missing: a core adopts the thread it is already executing, and an idle task is created already on its CPU - neither passes through READY because neither was ever waiting. The refusal named the slot, both states and the function, which is the argument for a table over scattered assignments in one line of output.

Storage still lives in the architecture's field; what moved is the decision about whether a change is allowed. Moving the storage is S-P3's work.


S-P2 — The run queue

Objective. Picking the next task is a tested data structure.

Files created — kernel/sched/runq.c, tests/kernel/runq_tests.c

Steps, in order

  1. The queue as a pure structure with host tests: add, remove, pick, round-robin fairness, the idle fallback, and two CPUs never picking the same slot - which is a defect this project has actually had.
  2. Replace hw_pick_next.
  3. Add the layering check: nothing under g_sched_lock allocates, frees or walks page tables. Enforced by review plus a grep, as the memory manager's is.

Done when. hw_pick_next is a wrapper over the tested queue; the host tests cover the cases; boot rate unchanged against the parent commit.

Status: done (97ae443). Seven host cases, four sabotage cases, all red. 21 boots clean out of 24, which is the background rate, with all four task counters zero in the failing boots as well.

One real defect fixed in the move. The old scan started from the current task, which is fair while something is running and degenerates when the current task is idle: the search then begins at the same place every time and the high-numbered slots wait behind the low ones. There is a cursor per CPU now, and the fairness case - every runnable slot must run within forty picks - is what catches it.

And one lesson about the shape. The queue does not decide what may run; it asks through a callback, so the state machine stays the single source of truth about what READY means. The sabotage case that matters is removing the question.


S-P3 — Lifetime: exit, reap, exec

This is the phase that repairs the defects.

Files created — kernel/sched/lifetime.c, tests/kernel/lifetime_tests.c

Steps, in order

  1. vibeos_task_exit and vibeos_task_reap in the new layer, with the two ordering rules from the architecture document made structural rather than remembered.
  2. The address-space ownership question routed through the single shared test for exit, exec and fork's failure paths alike.
  3. Fork and clone move in; the failure paths release what they allocated before publishing the slot.
  4. Delete the old paths.

Out of scope. Signals, which have their own lifetime questions and their own phase later.

Tests

  • Host: a fake task table exercising exit-while-sibling-runs, reap racing exit, exec with and without siblings, fork failing at each allocation point.
  • Sabotage: scripts/dev/cases/sched-lifetime.txt - announce before tearing down; skip the shared-address-space test in exec; publish a slot in fork's error path without destroying what it built.

Done when. All four ordering rules are in one file; the sabotage cases red; the boot rate measured against the parent commit rather than against a number.


S-P4 — Accounting: what each task actually costs

Objective. The machine can say how much CPU every task has had. Nothing above this phase is possible without it, and none of it exists today.

Files created — kernel/sched/account.c, tests/kernel/account_tests.c

Steps, in order

  1. A per-task tick count and a wall-clock stamp of when it was last scheduled, updated in the one place the context switch happens.
  2. Per-CPU idle time, so "the machine is busy" and "one task is busy" stop being the same number.
  3. tasks reports it; the boot gate asserts that the accounted time is within a bound of the elapsed time, which is what catches time being lost or double-counted.

Out of scope. Using any of it to make a decision. That is S-P5.

Done when. Accounted CPU time across all tasks plus idle matches elapsed time to within one tick per core.


S-P5 — Policy: priorities, fairness, affinity

This is the phase that makes it a scheduler rather than a rotation.

It is last on purpose, and the ordering is the argument: a policy decides which of several correct choices to make, so it can only be built on a lifetime layer that is correct, and it can only be evaluated with the accounting from S-P4. Building it earlier means tuning a thing whose behaviour is dominated by a defect.

Files created — kernel/sched/policy.c, tests/kernel/policy_tests.c

Steps, in order

  1. Time slices, stated rather than implied. The timer preempts today and the quantum is whatever the timer period happens to be. It becomes a number with a name, per class.
  2. Priorities and classes. A static priority per task, a small number of classes, and the rule that a higher class runs before a lower one. nice becomes a syscall that does something.
  3. Fairness within a class. Weighted round-robin over the accounted time from S-P4 - the simplest thing that is provably not starving anybody, and testable as a data structure.
  4. Affinity and balance. A task may be pinned; an idle core may take work from a loaded one. Both are decisions the run queue makes, so both are host tests over the queue rather than boot experiments.
  5. Priority inversion. Once priorities exist, a low-priority task holding a lock a high-priority task wants is a new failure mode. The kernel's locks are spinlocks with interrupts masked, so the answer here is probably "do not hold one across a scheduling point" enforced by a check, rather than inheritance - but that is decision T7 and not mine to settle.

Tests

  • Host: a task that never runs is a failure the queue reports; weights produce the ratios they promise over a long run; a pinned task never appears on another CPU; stealing does not take a running task.
  • Gate: no task in the boot goes more than N ticks without running.
  • Sabotage: scripts/dev/cases/sched-policy.txt - ignore the weight; let the highest priority starve everything; steal a running task.

Done when. A starvation test passes, the weights are measured rather than asserted by inspection, and tasks shows the accounting that justifies them.


S-P6 — Acceptance

  • Fork storm. A service that forks until slots run out, then exits: refusals counted, no panic, tasks returns to its starting shape.
  • Thread churn. Threads created and joined while the process execs, which is the combination that produced the execve defect.
  • Soak. The counters return to their starting values after a boot's work.
  • The state machine is asserted, not merely exercised: the gate fails if any illegal transition was attempted.

What this plan does not answer

Decisions are in decisions.md and are asked before the phase that needs them, not settled here.

Tasks and Scheduling: Decisions

Questions I will not settle on my own, and the ones already settled, with the reasoning kept rather than the conclusion alone.

The rule from the memory manager applies here too: a decision is asked before the phase that needs it, not at the start. Deciding early means deciding with less information, and this plan has already been wrong twice about things that looked settled.

Open

#QuestionWhy it is not mine to settleNeeded before
T1Does a task reference become (slot, generation) everywhere, or only where a stale index has already bitten?Everywhere is safer and touches perhaps forty call sites; selectively is cheaper and leaves a rule with exceptions, which is how the exec defect happenedS-P1
T2kernel/sched/, or fold into kernel/core/?The same question D1 answered for memory, and the answer may differ: kernel/core/ now has real runtime workS-P1
T3Is the task table's size still 24, or does it become a build-time parameter?24 is enough for what runs today and a fork storm is the acceptance test; making it configurable is a small change that invites a large oneS-P1
T4Does the run queue stay round-robin, or is a ready-list per CPU with work stealing worth it now?A policy question with a cost this workload cannot measure; my instinct is no, but it is a product decisionS-P2
T5Should execve kill sibling threads, as POSIX says, or keep refusing to destroy their address space?POSIX is clear and this kernel is not POSIX; killing threads from exec is a lifetime change with its own racesS-P3
T6How is a boot-count criterion expressed, given the pre-existing flakiness?A ratio against the parent commit is my proposal; the alternative is to fix the flakiness first, which is open-endedS-P2
T7With priorities, how is priority inversion handled - inheritance, or a rule that no lock is held across a scheduling point?Inheritance is real work and this kernel's locks mask interrupts, which may make the rule sufficient; getting it wrong is a hang under loadS-P5
T8How many scheduling classes, and does anything need real-time guarantees?A product question about what VibeOS is for; the answer changes the queue's shapeS-P5

Taken

T0 — Counters before code, decided 2026-08-31

S-P0 lands first, exactly as the memory manager's P0 did.

The argument is not symmetry. The memory manager's counters were what turned "something is wrong with memory" into "this frame, this process, this operation", and every hour saved after that was bought by the two hours spent building them first. The task subsystem currently has three provenance fields, each added after a bug had already cost a session, and no way to see the table at all on a running machine.

The cost of doing this first is one phase of no visible progress. The cost of not doing it is paid the first time a task defect appears and the only tool is reading the code - which, in this project's own record, has reached the wrong answer six times in one day.

Program Loading: Rewrite Plan

The subsystem that turns a path into a running process. Written to the same rules as the memory manager's plan, and second in order because it is what blocks the memory plan's P4 step 3: execve cannot fault pages in while it is a single function that reads a whole program into a staging buffer.

Companions: architecture.md, phases.md, decisions.md.

1. Why this is a rewrite and not a fix

execve today is one long function inside an 8312-line file. In sequence it: reads a whole ELF off the disk into a 4 MiB staging buffer under a lock that masks interrupts; parses it; maybe reads a second file, the interpreter, into a second 2 MiB buffer; creates an address space; maps every page of both images by copying out of those buffers; builds the startup block on the stack; destroys the outgoing address space; and rewrites the task's registers.

Every one of those steps has produced a defect, and the reasons rhyme:

  • The 2 MiB read under g_exec_lock was indistinguishable from a hang until the block driver learned multi-sector transfers. Interrupts are off for the duration.
  • A failed FAT lookup returned the end-of-chain marker, so a flaky sector produced a short file that claimed to be complete - and execve parsed whatever the previous program had left in the shared staging buffer.
  • execve destroyed the outgoing address space without asking whether sibling threads still ran on it. hw_task_exit had asked that for months; the two live two thousand lines apart.
  • The interpreter path is translated by string comparison against one hard-coded name, because FAT has neither the directory nor a name that long.

The staging buffers are the centre of it. They exist because loading is a copy rather than a mapping, and they are shared global state that a partly-failed load leaves dirty for the next caller.

2. The goal: production ready

PropertyWhat it means hereHow it is proved
No shared staging statetwo execs at once cannot see each other's bytesthe buffers are gone; a concurrent-exec test
Loading is mapping, not copyinga page of a program is faulted in from the page cachecache_hits dominate on a re-exec; the 4 MiB buffer no longer exists
Bounded latencynothing reads a disk with interrupts maskedno I/O under g_exec_lock; the lock's scope asserted
All-or-nothinga failed exec leaves the caller exactly as it wasfault injection at each step; the process still runs its old image
Truthful about short readsa file that cannot be fully read is refused, not parsedthe byte count is checked against the header, not against the directory
Isolatinga program never sees a previous program's bytesframes zeroed or fully written; the tail page of a file zero-filled
Observablea failed exec says which step failed and whyone log line per refusal, with the path and the reason
Host-testableELF parsing, segment layout and the startup block are portabletests/kernel/loader_tests.c; today only vibeos_elf_* is tested

Invariants

  1. No global buffer holds program bytes between two calls.
  2. A page of a program image is produced by the backing layer, not copied out of a buffer.
  3. An execve that fails at any step leaves the calling process running its previous image, with its address space and regions unchanged.
  4. A short read is a refusal. The size a directory claims is never trusted over the bytes actually delivered.
  5. The outgoing address space is released by whoever holds the last reference, asked through the one shared test.
  6. Nothing reads a disk while holding a lock that masks interrupts.
  7. Every refusal is logged once, with the path and the step.

3. Design goals

  1. Load by mapping. A segment becomes a region backed by the file, and its pages arrive through the page cache. This is memory-plan P4 step 3, and it is the reason this plan exists.
  2. The parse is portable and pure. Header, program headers, segment layout and the startup block are data transformations; they belong where they can be tested without a machine.
  3. One place decides the layout. Which window, what protection, where the interpreter goes, and what the stack looks like.
  4. Failure unwinds. The new address space is built beside the old one and installed only when it is complete - which is already how it works, and the plan is to keep it structurally rather than by care.
  5. The interpreter substitution is named as a stand-in. It exists because FAT cannot hold /lib/ld-musl-x86_64.so.1; the plan does not generalise it, it isolates it so that a real filesystem layout can delete it. (Deleted by docs/abi/ A4: FAT reads long names and the loader lives at that path.)

4. Non-goals

  • A real filesystem layout. The interpreter path substitution stays until there is one. (There is one since docs/abi/ A4, and the substitution is gone.)
  • Shared libraries beyond the single interpreter this kernel already loads.
  • MAP_SHARED file mappings, which belong to the memory plan.
  • Changing the two user windows, or where Linux binaries link.

5. Tracking

As the project rule requires: a row in docs/implementation_progress.md, a detail file, and the book summary regenerated.

Program Loading: Phases

Same rules: host tests and sabotage cases land with each phase, criteria are stated in terms the phase controls, and any boot count is a ratio against the same measurement on the parent commit - see boot_repeatability.md for why a bare count cannot be a verdict here.


X-P0 — Say what happens

Objective. A failed exec explains itself before anything is restructured.

Steps

  1. One counter per refusal reason: not found, short read, bad header, no interpreter, out of memory, image too large.
  2. One log line per refusal, with the path and the step. Today several refusals return -1 silently and the caller reports "cannot load".
  3. Gate assertion: the deliberate refusals a boot performs are the only ones.

Done when. Every return -1 in the exec path is preceded by exactly one line naming the path and the reason.


X-P1 — The parse comes out

Objective. Everything that is a data transformation is portable and tested.

Files created — kernel/exec/image.c, include/vibeos/image.h, tests/kernel/loader_tests.c

Steps, in order

  1. Segment layout: given headers and a bias, produce the list of (vaddr, len, prot, file offset) a loader must satisfy. Pure function, host-tested, including the cases that have bitten: holes between segments, a segment whose memory size exceeds its file size, a segment ending mid-page.
  2. The startup block builder: argv, envp and the auxiliary vector, with AT_ENTRY staying the program's own entry when an interpreter starts - the rule a dynamic program depends on and which is currently a comment.
  3. Point the existing loader at both; delete the inline versions.

Out of scope. Reading the file, mapping anything.

Tests

  • Host: nine layout cases, plus a startup block decoded back and compared.
  • Sabotage: scripts/dev/cases/exec-image.txt - lose the BSS tail; overlap two segments; put the interpreter's entry in AT_ENTRY.

X-P2 — Load by mapping, and the staging buffers go

This is the phase that repairs the defect, and it is memory-plan P4 step 3.

Files created — kernel/exec/load.c

Steps, in order

  1. A segment becomes a region backed by the file: vibeos_vma_insert with VIBEOS_BACKING_FILE, the file's id, and the segment's offset. The region layer already carries all three and advances the offset correctly on a split - that was tested at memory P3 precisely for this.
  2. The fault handler resolves a file-backed region through vibeos_cache_get, which already exists and is already asserted by the boot gate at a hit ratio.
  3. execve stops reading whole files. The ELF headers are still read eagerly
    • they are needed to decide anything - but the segments are not.
  4. Delete g_exec_elf and g_interp_elf, and the 6 MiB they hold.

Out of scope. Writable file mappings and write-back, which stay with the memory plan.

Tests

  • Host: a fault against a file-backed region returns the right page; a region split mid-file still faults to the right offset.
  • Gate: the staging buffers are gone from meminfo; a re-exec of the same program is served from the cache; no disk read happens under g_exec_lock.
  • Sabotage: scripts/dev/cases/exec-load.txt - fault in from the wrong offset; keep the staging buffer alive; read a segment eagerly.

Done when. g_exec_elf and g_interp_elf do not exist; the boot's exec path performs no read with interrupts masked.


X-P3 — All-or-nothing

Objective. A failure at any step leaves the caller running its old image.

Steps, in order

  1. Fault injection at each allocation and read point, driven by a boot-time seed like the memory stress service.
  2. The unwinding made structural: the new address space and region list are built beside the old ones and swapped in one step.
  3. The outgoing address space released through the single shared ownership test.

Done when. The injection service completes every seed with the parent still running its original program; counters return to their starting values.


X-P4 — Acceptance

  • Concurrent exec. Two processes exec different programs at once, repeatedly. Today they share two global buffers.
  • A truncated program is refused, not parsed - the failure that once made a short file look complete.
  • Re-exec is cheap: the second exec of a program reads nothing from disk.
  • The interpreter substitution is one function, marked as a stand-in, with a test that fails if a second hard-coded path appears. (Deleted with its check by docs/abi/ A4, once the loader could be found by path.)

Program Loading: Decisions

Asked before the phase that needs them, not settled here.

Open

#QuestionWhy it is not mine to settleNeeded before
X1Are ELF headers still read eagerly, or faulted in like everything else?Eager is simpler and keeps one small read; faulting them in removes the last special case but makes the first fault of an exec recursiveX-P2
X2What happens to a file-backed region when the file is written while mapped?Refusing the write, invalidating the cache, or copy-on-write are three different products. Today nothing noticesX-P2
X3Does execve keep a bounded eager read for small programs?A page-fault per page costs more than one read for a 4 KiB service; a hybrid is faster and is two code pathsX-P2
X4Does the interpreter substitution move to a real filesystem layout now, or stay a stand-in?It is a filesystem question, not a loader one, and doing it here would spread it. Answered by docs/abi/ A4: the layout became real and the substitution was deletedX-P2
X5Is the fault-injection service a boot service like svc-stress, or a console command?A service exercises it every boot and costs boot time; a command is opt-in and therefore forgottenX-P3

Taken

X0 — This plan follows the scheduler's, decided 2026-08-31

Program loading and task lifetime touch the same state - an exec destroys an address space, rewrites a task's registers and replaces its regions - and the defect that closed most recently (execve not asking whether siblings shared the address space) sat exactly on that boundary.

Doing the loader first would mean building on a lifetime layer that does not exist yet, and the ownership question would have to be answered twice. The scheduler plan's S-P3 puts that question in one place; this plan then uses it rather than reinventing it.

The cost is that memory-plan P4 step 3 waits for S-P3. That is stated plainly in the memory plan rather than left as a surprise.

Syscalls and the Linux ABI: Plan

Every Linux x86-64 syscall, and the refactor of the layer that answers them. Written to the rules the memory, scheduling and program-loading plans used: the goal stated as properties that can be checked, invariants before code, and each phase closed by a program somebody else wrote rather than by a test this project wrote for itself.

Companion documents: phases.md (the order and what closes each step) and syscalls.md (all 373 numbers, each with its state and its phase).

1. The objective

An unmodified Linux x86-64 program runs, and every syscall it can make has an answer that was decided rather than defaulted. Three answers exist and every number has exactly one:

  • implemented - with Linux's semantics, its errno values and its structure layouts, checked against Linux's own headers and tests;
  • refused by decision - a fixed errno, a sentence saying why, and a counter that says refused (loading a kernel module, raw port access, a syscall Linux itself removed);
  • deferred - no program in the corpus asks for it; answered with ENOSYS and counted separately, so the day one does, the gate says which.

What is not an answer: ENOSYS because nobody wrote the row. Today that is 302 of the 373.

2. Where it stands (2026-09-29)

Rows in the kernel73: 71 Linux numbers and 2 of VibeOS's own (netctl, pageinfo)
Done / partial / missing55 / 16 / 302 of 373
What runsstatic, PIE and dynamic musl binaries: BusyBox, a shell, threads, signals, TCP
Dispatcherone table-driven engine (C4): each row declares its pointer arguments and the engine validates them before the handler runs; check-syscall-checks.py holds the declarations against the call graph

The table works and is worth keeping. What limits the next three hundred rows is underneath it:

  1. The handlers are not portable. kernel/abi/linux/ includes arch_hw_internal.h and reaches g_tasks, hw_* and the page tables directly
    • about 700 references across 4,866 lines. A handler cannot be host-tested, so every syscall so far was proved by booting. (Closed by A2: the handlers reach the kernel through include/vibeos/ksvc.h and run in the host tests.)
  2. Four descriptors. The table holds descriptors 3 to 6, and an entry is a value copied on dup and fork - so two descriptors that Linux says share an offset do not. Most real programs open more than four files; all of them expect dup to share. (Closed by A3: descriptors name counted open file descriptions, 1024 of them, and each file type has its own operations.)
  3. One directory. There is no working directory: getcwd answers /, and openat accepts only AT_FDCWD. (Closed by A4: a working directory and root per process, one path walk through the mount table, dirfd honoured, and the loader's interpreter found by path.)
  4. Anonymous memory only. mmap refuses file-backed mappings and MAP_FIXED, which glibc's dynamic loader needs.
  5. No terminal. ioctl answers ENOTTY to everything, so a shell cannot ask for its window size or raw mode.
  6. ENOSYS means two things. A deliberately refused number and a missing one look the same to the counter and to the gate.

None of these is a missing syscall. They are why adding syscalls one at a time, the way the current 73 arrived, would not get far.

3. Invariants

The first five were written down in ../roadmap.md before this plan; they stand.

  1. Every syscall argument that names user memory is validated in one place, and a refusal says why (hw_user_range_why).
  2. An int argument arrives zero-extended and is compared through VIBEOS_ARG_INT, never on all 64 bits.
  3. A sigset_t is converted at the boundary and nowhere else.
  4. An unimplemented syscall is refused and counted, never silently succeeded.
  5. A wait status carries the signal in the low seven bits and the exit code in the high byte, and every producer and consumer agrees.
  6. Every number has one registry entry - implemented, partial with its gap named, refused with a reason, or deferred - and the dispatcher, the number file and syscalls.md are generated from or checked against it. Two statements of one number disagreeing is a build failure, as it already is for the rows.
  7. Missing and refused are different counters. A corpus program reaching a missing number fails the gate; reaching a refused one is expected and counted.
  8. Conformance is measured against somebody else's artefact. Errno values and structure layouts are checked against Linux's uapi headers; behaviour against the Linux Test Project and real programs. CLAUDE.md records why: a fixture this project writes can only prove the handler matches the fixture. Since A5 the layouts live in include/vibeos/linux_layout.h, tests/kernel/linux_layout_tests.c compares them, and check-linux-layout.py holds the comparison to the declarations.
  9. A handler is portable and host-testable. It reaches a task, its descriptors, its address space and the filesystem through a declared interface the architecture implements - not through arch_hw_internal.h. Since A2 that interface is include/vibeos/ksvc.h, and check-abi-layering.py holds every personality to it.
  10. A partial row names its gap in the registry, and the gap is the same sentence the refusal logs, so "partial" cannot quietly become "done".

4. Method

The syscalls a program needs are a property of its C library and of what it does, not of the kernel, so they are measured rather than reasoned about: scripts/dev/trace-linux-binary.sh runs a binary on the host under strace and lists what VibeOS does not serve. Every phase starts from that list for its target programs, and ends when those programs run under the gate.

The corpus grows by phase (see phases.md and corpus.md): BusyBox as the boot image stages it (a static glibc build), the musl test programs, sqlite3, lua, a small HTTP server, and the Linux Test Project's syscall tests built against musl. LTP is the conformance oracle - thousands of cases written by the people who define the behaviour, and exactly the kind of artefact neither side of this project controls.

5. Boundaries

The dispatcher, the registry, argument validation and the handlers are portable. The register ABI, the syscall trampoline and the frame that fork and signal delivery need are the architecture's. The Linux ABI is a translator onto the kernel's own operations (include/vibeos/abi.h, C4); a Windows or macOS ABI later is a second translator onto the same operations, which is why A2 declares the operations and not Linux's names for them.

6. What this plan does not do

It does not design a native VibeOS syscall ABI - ../syscalls.md records that intent, and C4's operation vocabulary is where it would start. It does not promise every deferred number: those wait for a program that asks. And it does not treat a number count as progress - a phase is done when its programs run, not when its rows exist.

Syscalls and the Linux ABI: Phases

Two parts. A rebuilds the layer the current 73 syscalls stand on, so that the next three hundred can be added and tested one at a time. L adds them, by capability, each phase closed by programs this project did not write. Numbers per phase are from syscalls.md.

Every step, in both parts, is done the way this project does things: a gate or host test that fails if the step regresses, a sabotage case that proves the test can fail, a row in the tracker for what it found, and the docs updated in the same change.


Part A - the refactor of what exists

A0. The corpus and the measurement

Objective. Know, per program, which syscalls it needs and which of them are missing - before writing any.

  • The corpus: BusyBox as the boot image stages it (Ubuntu's busybox-static, which is linked against glibc), the musl test programs, and - from their released sources, built against musl - sqlite3, lua, and LTP's testcases/kernel/syscalls.
  • A checked-in needs list per workload, measured under strace on Linux, with the system files each opens.
  • A report: for each workload, needed / done / partial / missing; across the corpus, the missing ones by phase and by who asks.

Done when the report exists for every corpus program and runs in the nightly.

Status (2026-09-29): done. tests/corpus/ holds 25 workloads - sixteen of BusyBox, the six musl test programs, two of sqlite3 and one of lua - measured by scripts/dev/corpus-measure.sh and reported by scripts/dev/corpus-report.py in corpus.md: 80 distinct syscalls asked for, 41 done, 16 partial, 23 missing, 13 workloads ready (Lua among them; sqlite3 waits on fcntl locks, pread64/pwrite64 and fsync). The musl programs come out ready, which is the report's own check. scripts/dev/corpus-build.sh downloads SQLite, Lua and LTP from pins with their SHA-256 (tests/corpus/sources.txt) and builds them static against musl. 1,530 of LTP's syscall tests build: 1,331 matched to 320 syscalls - an oracle for 73 of L1's 78 - and three directories do not build (fmtmsg, timer_create, utils). The nightly job corpus-needs builds SQLite and Lua, measures again and fails on drift.

The first LTP build produced 378 tests, not 1,530: its top-level make stops at the first directory that fails, -k or not, and fmtmsg fails early in the alphabet. The script builds each directory on its own now. LTP's tests are measured as an oracle - which exist and build - not traced one by one: most need root on the host, and what they exercise is by construction the syscall they are named after.

On the way, trace-linux-binary.sh - the tool this whole method rests on - turned out to report full coverage for every binary: it looked for handlers as case LSYS_ in arch_hw.c, gone since C4, and its name extraction no longer matched strace, so both lists were empty. It reads the table now (M-077).

A1. The registry

Objective. One source of truth for all 373 numbers (invariant 6), and two counters where there is one (invariant 7).

  • A registry file (number, name, state, phase, reason) replaces scripts/dev/linux-syscall-numbers.txt, which listed only the implemented ones. It is generated once from the kernel's arch/x86/entry/syscalls/syscall_64.tbl and edited by hand after that.
  • The dispatcher answers a refused number with its registry errno and counts abi_refused; a missing number counts abi_missing. The gate fails on abi_missing from a corpus program, as it already does for an unimplemented call today.
  • syscalls.md is generated from the registry by a script in the book build, instead of by hand.
  • check-syscall-checks.py also checks each row against the registry.

Done when the three statements of a number (row, registry, table) cannot disagree without check.sh failing, and a sabotage that marks a refused number missing turns the gate red.

Status (2026-09-29): done. The registry is kernel/abi/linux_syscalls.def, an X-macro list - SYSCALL(number, name, state, phase, errno, "why") - that the C code and the scripts both read: 373 Linux numbers seeded from Linux's asm/unistd_64.h and the plan's table, plus VibeOS's two own. States are DONE, PARTIAL, MISSING, DEFERRED and REFUSED; each of the 33 refusals carries its own reason, seven of them answer EPERM rather than ENOSYS.

  • The dispatcher answers a number with no row from its line: a refusal with its errno, counted as refused; a deferred call with ENOSYS, counted and reported by the gate by number (abi_deferred_syscall_nr); a missing one as before (abi_unimplemented_syscall_nr). hello asks for iopl on purpose and expects EPERM, so the refusal count is seen moving on every boot (abi_refused_unproven otherwise).
  • The boot refuses to start when a row and its line disagree in either direction, and check-syscall-checks.py holds the same rule against the sources - reading the registry instead of linux-syscall-numbers.txt, which is gone. A registry line it cannot parse is a failure: the first version skipped one with a space before a comma, which the compiler accepted.
  • syscalls.md is written by make-syscall-table.py from the registry, and check.sh runs it with --check, so the published table fails the build when it drifts. Not generated in the book build, as planned: a stale page is caught before the push, which is earlier.
  • Host test test_linux_registry; sabotage abi-registry.txt (iopl missing, iopl deferred, read without a row - each red for its own reason) and abi-registry-dispatch.txt (the refusal path removed, the count removed). check-sabotage-anchors.py did not know .def files and reported the first as anchored nowhere; it does now.

A2. Handlers leave the architecture

Objective. Invariant 9: kernel/abi/linux/ stops including arch_hw_internal.h.

  • A declared interface for what a handler needs: the current task and its identity, its descriptor table, its address space (map, protect, unmap, fault a user range in), the filesystem, the network stack, and wait/wake. Implemented by the architecture; a fake implementation for host tests.
  • hw_sys_* renamed linux_sys_*; the helpers that only handlers use move with them, as in C4.
  • Host tests per handler, beginning with the 16 partial rows, whose gaps are written as failing expectations that the owning L phase turns green.

Measure. The ~700 references to hw_*/g_tasks in kernel/abi/linux/ go to zero; blast radius for "add a syscall" goes to one file plus its row.

Done when the Linux ABI builds into the host test binary and the existing boot gate is unchanged.

Status (2026-09-29): done. Nothing under kernel/abi/ names the architecture any more: no hw_*, no g_tasks, no architecture global, no inline assembly, no arch_hw_internal.h. Every hw_sys_* is linux_sys_*.

  • The interface is not Linux's. It is include/vibeos/ksvc.h, kernel services, with a ks_ prefix: 92 functions over tasks, locks and waiting, user memory, the address space, processes, registers and devices. A second personality - Windows is planned - is another directory beside kernel/abi/linux that includes the same header. The architecture implements it in kernel/arch/x86_64/ksvc.c, mostly one line over a function that already existed. What is more than a line is what used to sit in the handlers and is really about the machine: the mm lock's interrupt window, a fresh page's leaf bits, reading an entry for pageinfo, a fork's or a thread's registers, an exec's entry, the signal frame, the TLS MSR.
  • Portable structures. The process state, the image and the lock layout moved to include/vibeos/procstate.h, and the architecture keeps its names for them as typedefs. Linux errno and signal numbers moved to abi_linux.h. The layer's exports to the kernel - the syscall entry, signal delivery, descriptor copies, futex wake - are include/vibeos/linux_exports.h.
  • Reusable across personalities. The pointer checks a row declares run in kernel/abi/abi.c (vibeos_abi_check_pointers, handed the personality's own range check and bad-address error), and the row macros are include/vibeos/abi_rows.h. The architecture's entry only reads registers; the dispatcher is linux_syscall(frame, nr, a[6]).
  • Moved, not rewritten. The futex table and its wake left the architecture's task code for kernel/abi/linux/futex.c, with the wait. The descriptor claim and a pipe end's release left it for fs.c. mmap's two mapping loops, which differed only in the leaf, became one.
  • Host tests. tests/kernel/ksvc_fake.c is the other implementation: a task table, user memory with a window that can be made to fault (a sibling's munmap between the check and the copy), a filesystem and a network stack in memory, and waits, exits and panics that return to the test instead of hanging it. linux_abi_tests.c runs the handlers through linux_syscall: pipes, dup2, the console, the pointer engine, sigset numbering, sigaction, kill across sessions, a forged signal frame, the registry's three answers, fork. Every PARTIAL row has a gap expectation written as what Linux does; it fails today for the reason the registry names, a gap that starts passing fails the test until the line says DONE, and a gap for a line that is not PARTIAL fails too. rseq, partial by decision (phase R), asserts the decision instead.
  • Checks. check-abi-layering.py (in check.sh): no personality file names the architecture - the forbidden globals are read from arch_hw_internal.h, after the first version knew only g_tasks and let a sabotage reading g_timer_ticks through - and both implementations define every service. check-syscall-checks.py followed one-line definitions into the next function (its body ended at the next \n}\n), which made close "reach" the user-range check through ks_lock; it matches braces now, and its listing is identical to the one before the refactor for all 68 operations. check-chokepoints.py counts include/ too and the ks_ doors beside the hw_ ones. 26 sabotage anchors were re-pointed, 12 by applying the code's own renames and 14 by hand.
  • Sabotage. abi-host-handlers.txt (sigset numbering, a gap closed without the registry, a forged frame resumed) and abi-layering.txt - all red for their stated reason.

Measure. References to hw_*/g_tasks in kernel/abi/linux/: ~700 to 0. Blast radius for "add a syscall" stays 2 (abi.h and the file with the handler), plus its registry line - not the one the plan hoped for: the second is the operation's declaration in abi.h, which every personality shares and which is kept on purpose.

A3. Descriptors as Linux has them

Objective. Fix the two descriptor defects under every I/O syscall.

  • An open file description: a refcounted object holding the offset, the flags and the type-specific state, shared by dup, dup2, dup3, fork and SCM_RIGHTS later. The descriptor table holds references to it, not copies.
  • A table that grows to RLIMIT_NOFILE (default 1024), with FD_CLOEXEC honoured at exec and close_range.
  • One operations table per file type - regular file, directory, pipe, socket, terminal, and later eventfd/timerfd/signalfd/epoll - so read, write, lseek, fstat and ioctl dispatch by type instead of by the if chains they use today.

Done when a host test shows two duped descriptors sharing an offset, a program opens 200 files, and descriptor sharing between threads (M-072) still passes.

Status (2026-09-30): done.

  • Open file descriptions (include/vibeos/file.h, kernel/fs/file.c): a counted object holding the offset, the status flags and each type's state, in a pool of 256 with its own lock. The last reference runs the type's release - a pipe end given back, a socket closed, a written file committed - and a slot is not handed out again until that release has finished. A put with nothing to put is file_put_underflow in the must-be-zero registry.
  • The table (fdtable.h) holds references and one flag per number, close-on-exec, and grows a page of 256 at a time up to 1024 (RLIMIT_NOFILE, which prlimit now reports). Descriptors 0-2 are ordinary entries: a spawned program gets one console description on all three, so closing 1 and opening a file gives the file 1, as every shell's redirection expects. fork copies references, exec copies and then drops the close-on-exec numbers, and the last thread to leave destroys the table.
  • One operations table per type, in kernel/abi/files/ beside the personalities - regular file, directory, pipe end, socket, console - written against vibeos/ksvc.h, so a Windows personality uses the same ones. read, write, lseek, fstat, ioctl and getdents64 dispatch by type: a pipe is a FIFO and a socket a socket to fstat, both ESPIPE to lseek. The socket waits (connect, accept, recvfrom) moved with the type, leaving the Linux handlers the sockaddr translation only. Pipe ends are counted per description, so dup and fork no longer touch a pipe's counts; the five sites that had to remember an acquire became none.
  • Every call holds a reference to the description it works on for its whole length (Linux's fdget/fdput), so a sibling thread's close takes the number away and not the file. A socket call still re-checks the socket's own tenancy (M-020), because a process's exit releases the sockets it owns.
  • New and finished syscalls: dup3 (DONE), fcntl (PARTIAL: descriptor and status flags, F_DUPFD and F_DUPFD_CLOEXEC; record locks answer ENOLCK rather than pretend), close_range (PARTIAL: CLOSE_RANGE_UNSHARE refused), pipe2 honours O_NONBLOCK and O_CLOEXEC, open honours O_CLOEXEC, socket honours SOCK_CLOEXEC. open stays PARTIAL for the working directory only.
  • Host tests: dup shares an offset, 200 files at consecutive numbers, a closed stdout reused, a pipe is a FIFO with ESPIPE and non-blocking EAGAIN, close-on-exec through open, dup3, F_DUPFD_CLOEXEC and close_range, and a forked child reading on from its parent's offset; the table and description layers have their own. The open gap for four descriptors closed on its own when the table grew and failed the test until the registry said so, which is the gap mechanism doing its job; its expectation is the working directory now, and fcntl and close_range carry theirs.
  • Checks: check-net-stable.py follows the waits into socket.c and matches braces - its one-line signature parser saw two of four waits and said so instead of passing. check-task-identity.py's count of lines indexing the table's arrays went to zero. The host test runner is unbuffered: a sabotage of the table's page clearing was named by its test and the line was lost when a later group crashed on the same garbage.
  • Sabotage: fs-file.txt (3), fs-fdtable.txt rewritten (4 - the C5 cases named code that no longer exists), abi-files.txt (3), ten anchors re-pointed; all red for their reason, the page-clearing case by its test and then a crash.
  • Not done: the write-back still replaces a whole file with at most 512 buffered bytes, so O_RDWR on an existing file opens for reading only - a gap of the filesystem layer, recorded in regular.c. And eventfd, timerfd, signalfd and epoll are future types (L4).

A4. Paths

Objective. Relative paths, a working directory, and the *at family.

  • A per-process working directory and root, inherited by fork and threads.
  • One path walk through the mount table: ., .., mount crossings, symbolic links where the filesystem has them, ENAMETOOLONG/ELOOP/ENOTDIR as Linux reports them.
  • dirfd honoured everywhere AT_FDCWD is today.
  • The /lib/ld-musl-x86_64.so.1 substitution in the loader is deleted, as CLAUDE.md says it should be the day the layout becomes real.

Done when cd in a shell works, relative open works, and the loader finds its interpreter by path.

Status (2026-09-30): done.

  • One path walk (include/vibeos/path.h, kernel/fs/path.c), shared by every personality. A path is made absolute and normalised lexically against a root and a working directory - ., .. and repeated slashes, with .. stopping at the root - and then looked up through the mount table, so a path under a mount point reaches that filesystem rather than the boot volume. Every component before the last must be a directory (ENOTDIR otherwise), a component over 255 bytes or a result over 256 is ENAMETOOLONG rather than cut short. vibeos_path_parent answers for a name about to be created: its parent must exist, the name need not.
  • A working directory and a root per process, in the process state beside the descriptor table and under its lock, inherited by fork, threads and exec. An open file description remembers the mount it was opened on, so a read, a directory listing and a write-back reach the right filesystem.
  • dirfd is honoured by every *at call (linux_path_at): AT_FDCWD is the working directory, a directory descriptor its own path, anything else EBADF or ENOTDIR. New and finished rows: chdir, fchdir, getcwd (ERANGE for a short buffer), mkdirat, openat, open all DONE; unlinkat PARTIAL, since AT_REMOVEDIR has no filesystem here that removes a directory. readlinkat and newfstatat resolve relative paths too.
  • exec resolves its path against the working directory, and argv[0] stays the name the caller gave - BusyBox still dispatches on it.
  • The interpreter substitution is deleted. The loader is staged at /lib/ld-musl-x86_64.so.1 on the boot volume and the kernel opens whatever path the program's PT_INTERP names. check-exec-layering.sh, which existed to keep the substitution to one function, went with it.
  • FAT reads long names. It had to: the loader's name is not 8.3. VFAT entries are collected in front of their short entry and used only when their checksum matches it, so an orphaned long name left by a tool that did not know VFAT is ignored rather than misattributed. The ESP writer emits them, and the gate mounts a second image written by mtools (FATLONG.IMG, at /fatlong) and reads a file through a long directory name - an artefact neither side of the test produced, after the ISO9660 lesson.
  • Gated: the boot self-test runs cd -P /DOCS && pwd -P && cat NOTES.TXT in the shell; shell_cd_did_not_work fails the boot unless the program printed /DOCS and the relative cat succeeded. The musl interpreter is loaded by path on every boot that runs a dynamic binary.
  • Host tests: path_tests.c (normalisation, the root floor, NAME_MAX, the walk across two mounts, ENOTDIR and ENOENT, the parent rule) and, in linux_abi_tests.c, the working directory, relative open, the at calls with a directory descriptor, the errors, and fork inheriting the directory.
  • Sabotage: fs-path.txt (4, host), abi-paths.txt (2), fs-fat-longnames.txt (2), and a case for the new image row in io-filesystems-gate.txt; every one red for its reason. One of them only on the host: a directory descriptor ignored in favour of the working directory boots green, run to confirm it, because nothing the self-test runs opens relative to one. Breaking the long-name reader fails the image row and the dynamic binary - the loader's name is a long one.
  • Not done: symbolic links and ELOOP - no filesystem here has them, so there is nothing to follow; chroot stays refused, although the root is now a real field. Both belong to L1.

A5. Layouts and errno from Linux's headers

Objective. Invariant 8 for structures: stat, statx, dirent64, timespec, sigaction, siginfo_t, sockaddr_*, rlimit, utsname, termios.

  • A host test compiled against the host's linux/*.h uapi headers asserts every offset and size the kernel writes, and every errno value it returns.

Done when the test exists and a sabotage that moves one field turns it red.

Status (2026-09-30): done.

  • One declaration of Linux's layouts (include/vibeos/linux_layout.h): stat, __kernel_timespec, new_utsname, sysinfo, sockaddr_in, iovec, rlimit64, the kernel's sigaction, and the getdents64 record, with Linux's field names and padding named too - plus every constant a handler compares a user's argument against: the *at flags, fcntl commands, close_range, clone, wait4, mmap, prctl, arch_prctl, futex, rlimit, sigprocmask, socket and dirent-type numbers. The handlers used to write byte offsets and bare numbers (rec[18] = 4, STAT_OFF_UID 28u, domain != 2u); TIOCGPGRP and AT_FDCWD were spelled twice in two files, and the registry spelled EPERM and ENOSYS a second time. Each handler now fills a structure and copies it out once.
  • The file layer's numbers are Linux's by decision, and named so: the open flags and mode bits were already VIBEOS_O_* and VIBEOS_S_IF*; seek origins (VIBEOS_SEEK_*) and the console's ioctl requests (VIBEOS_IOCTL_*) joined them. getdents64 is the one file operation whose output is a personality's, and says so.
  • The test (tests/kernel/linux_layout_tests.c) compiles against the host's uapi headers and makes 232 comparisons: every field's offset and size, every structure's size, every errno value the kernel returns, every signal number, flag and constant. What uapi does not carry - struct dirent64, AF_INET, SOCK_*, DT_*, and the S_IF* types that linux/stat.h withholds from a glibc build - comes from the C library's headers, in a second file (linux_layout_libc.c), because the two sets of headers cannot share one. On a non-Linux host it says it was skipped; on Linux the includes are unconditional, so a runner without the headers fails to build rather than passing having compared nothing.
  • The test cannot fall behind the header: check-linux-layout.py (in check.sh) fails when a field, a structure's size or a Linux-valued constant is declared and not compared, and when a file under kernel/abi/ defines a constant under Linux's own spelling. Its first run found FUTEX_CMD_MASK, which is Linux's name for a different value (~(PRIVATE | CLOCK_REALTIME), not 0x7F); it is VIBEOS_FUTEX_OP_BITS now.
  • Sabotage: abi-layout.txt (5: two fields swapped at equal size, a field widened, the dirent64 order, mem_unit widened where the structure's size cannot tell, AT_FDCWD off by one), abi-errno.txt (2), dev-linux-layout.txt (3, against the check); all red for their reason. AT_FDCWD is named by the layout test and by the handler tests, which run first.
  • The sabotage tool had a hole, and two cases had never run. sabotage.py drops lines starting with # as comments, so an anchor that is a #define vanished, the case ran with an empty anchor, changed nothing and scored NOT RED - which is how the errno cases first came back. check-sabotage-anchors.py skipped an empty anchor too. Both refuse one now, and \# at the start of a line is a literal #. That found core-dispatcher.txt's fence case and services.txt's "only one service is started", which had never tested anything; both are escaped and red now - the fence case by check-reachable.py, the manifest case at boot as missing:[CRASH] end: with one service started, svc-crash never runs and the crash record the gate waits for never comes.
  • Not declared, because nothing writes them yet: statx (L1), siginfo_t and a Linux ucontext (the signal frame is this kernel's own layout, and SA_SIGINFO handlers get no siginfo - L2), termios (the console answers ENOTTY). Each arrives with the phase that writes it, and the check makes it arrive with its comparison.
  • Found on the way: getdents64's gap reason said "FAT names", which has been wrong since A4 gave FAT long names; names are cut at 15 bytes by the listing buffer, which is what the registry says now. Still L1's.

Part L - the syscalls, by capability

Each phase names its target programs. It is done when they run under the gate and their LTP cases pass, not when its rows exist.

L1. Files and paths (72 missing, plus 6 partial finished)

stat, lstat, access, pread64/pwrite64, fcntl, flock, fsync, truncate, rename, rmdir, link/symlink/readlink, chmod/chown, umask, utimensat, statfs, the whole *at family, dup3, preadv/pwritev, statx, copy_file_range, sendfile, close_range, openat2, sync. Extended attributes answer ENOTSUP from the filesystem.

A minimal terminal: the console becomes a TTY file type answering TCGETS, TCSETS, TIOCGWINSZ and TIOCGPGRP/TIOCSPGRP, which every interactive shell asks for first.

Programs: BusyBox's file applets and its test suite (ls -l, cp -a, mv, find, tar, sort, grep -r, sed -i), sqlite3 on a file.

Where it starts from (2026-09-30). The syscalls are the small part. Only FAT is writable, and only a whole file at a time: a descriptor buffers 512 bytes and the file is rewritten from them when the last reference goes, so a program that writes more gets short writes. The filesystem interface (vibeos/vfs.h) has lookup, read, whole-file write, list, unlink and mkdir - no write at an offset, truncate, rename, rmdir, links, attributes, statfs or sync. ext2, exFAT, NTFS and ISO9660 are read-only. There is no wall clock (time is uptime) and no credential model (L2). stat, rename, chmod and symlink need something true to report before they are worth a row, so L1 builds that first:

  1. The filesystem interface grows the operations - write and truncate on a node, create, rmdir, rename, link, symlink and readlink, attributes (mode, owner, times, link count), statfs and sync - optional per driver, with the wrapper answering as Linux does when one is missing: EROFS from a read-only filesystem, EPERM from one that cannot represent the thing. The path walk follows symbolic links (ELOOP after 40) and answers lstat's question too.
  2. tmpfs (kernel/fs/tmpfs.c): the filesystem with all of it - modes, owners, hard and symbolic links, timestamps, rename over an existing name, sparse files - mounted at /tmp. Portable, host-tested, and a nightly torture against a model, per the rule that every module gets one.
  3. Writing through a descriptor: regular files write at their offset through the node, with O_CREAT/O_EXCL/O_TRUNC/O_APPEND as Linux means them, and pread64, pwrite64, preadv(2), pwritev(2), truncate, ftruncate, fsync, fdatasync, sync, syncfs, fallocate, fadvise64, readahead, copy_file_range, and sendfile served rather than refused. A filesystem without in-place writes keeps the whole-file path, without the 512-byte ceiling.
  4. FAT writes in place: write at an offset, truncate, rename and rmdir in the FAT driver, so the boot volume is a real filesystem too.
  5. Names and metadata: stat, lstat, statx, access and the faccessats, rename and renameat(2), rmdir and AT_REMOVEDIR, link, symlink, readlink against real links, creat, mknod, openat2, the chmod, chown and utime families, umask, statfs/fstatfs.
  6. The rest of the file calls: flock and fcntl record locks, getdents, getdents64 without its 15-byte names, the xattr calls (EOPNOTSUPP from every filesystem here), sync_file_range, close_range's UNSHARE.
  7. A minimal terminal: the console answers TCGETS, TCSETS, TIOCGWINSZ and the process-group requests, with ECHO and ICANON honoured.
  8. The programs: the BusyBox file workloads of the corpus run in /tmp under the gate, sqlite3 on a file, and the LTP cases for these syscalls staged and run.

Step 1 (2026-09-30): done.

  • vibeos_fs_node_t carries mode, link count, owner and three times. The lookup wrapper zeroes the node before asking the driver and completes what the driver left out (a type from is_dir, 0755 or 0644, one link), so five drivers written before the fields existed hand no stack garbage to stat.
  • Eleven operations beside the original six - write_at, truncate, create, rmdir, rename (with NOREPLACE), link, symlink, readlink, setattr, statfs, sync - each optional, returning 0 or a negated errno. A missing one is EROFS from a filesystem that writes nothing, EPERM from one that writes but cannot do this, EOPNOTSUPP for write_at/truncate/create where the caller keeps a whole-file fallback. vibeos_fs_set_clock is the registration timestamps read from. The five drivers' tables are designated initialisers now; positional ones were one field away from a hole.
  • vibeos_path_walk replaces vibeos_path_lookup and vibeos_path_parent: it takes a root, a base directory and the path as written, follows links one component at a time (a relative target from the link's directory, an absolute one from the root, ELOOP after 40), applies ".." to the path resolved so far - so link/.. is the parent of where the link points - and with CREATE answers for a missing last component where it would be made. NOFOLLOW and a trailing slash behave as Linux's do.
  • The Linux handlers walk through linux_walk_at; newfstatat honours AT_SYMLINK_NOFOLLOW and reports owner, link count and times, unlinkat and mkdirat do not follow the last component, exec runs the program a link points at, and readlinkat reads real links (the /proc/self/exe answer is kept, recognised before a walk would call /proc missing).
  • Errno values for what comes next (EACCES, EBUSY, EXDEV, EFBIG, ENOSPC, EROFS, EMLINK, ENOTEMPTY, ELOOP, EOPNOTSUPP), and S_IFMT, S_IFBLK, S_IFLNK - all compared with Linux's headers, which the layout check made non-optional.
  • Host tests: 16 walk cases (links relative, absolute, chained, across a mount, in the middle of a path, cyclic, dangling, a target shorter than the link with path after it) and 12 for the wrappers. Sabotage: fs-path.txt gained 5 cases and fs-vfs.txt has 4; all red. Removing the link bound is red with ENAMETOOLONG rather than a hang: every splice leaves a separator behind, so the pending buffer refuses a cycle too - a second defence, and not the right errno.

Step 2 (2026-09-30): done.

  • kernel/fs/tmpfs.c: 1024 inodes; a file's data in pages through sixteen direct pointers, one indirect and one double-indirect page (up to about a gigabyte), a page never written is a hole; a directory is a file of fixed-size records holding whole names. Modes, owners, three times, hard and symbolic links, rename over an existing name (NOREPLACE checked first, as Linux does; a directory cannot move inside itself), rmdir of empty directories, statfs with TMPFS_MAGIC. A freed inode's generation moves on, so a description left holding it gets ENOENT rather than the next file's bytes - the known gap is that the last unlink frees at once, before the last close.
  • It never allocates under its own lock. The allocator can reclaim, and the lock masks interrupts; so an operation takes up to three spare pages first, the locked code takes from them, and the rest go back after. Writes go a page at a time for the same reason. The host test's allocator fails the test if it is called with the lock held.
  • Mounted at /tmp at boot, 32 MiB at most, its pages through the admitted door for user memory; timestamps read uptime until L2 has a wall clock. The gate asserts tmpfs_not_mounted from its own line, and the shell writes a file there and reads it back (tmpfs_round_trip_failed - the contents are the shell's arithmetic, so the echoed command cannot stand in for them).
  • A must-be-zero, tmpfs_bad_record: a directory record naming a free inode, or one without its page, is refused as EIO and counted.
  • Host tests (tmpfs_tests.c, 70 checks besides the helper) and a torture against a model that shares no code with it (tmpfs_torture.c: eleven paths, twelve operations, every return value, every byte read, every path's type, size and link count, and the pages and inodes held, after every round). It is the nightly tmpfs-torture job, 300 seeds sanitized, which covers kernel/fs - the nightly-coverage baseline goes from 8 to 7.
  • Sabotage: fs-tmpfs.txt, 11 cases, all red under the host tests and 10 of them under the torture as well. Two lessons on the way. "The source's record is not found again" went NOT RED twice: with the target last nothing moves, and with the target just before the source the stale slot is exactly the new end, so the wrong removal removes the right record - only a record between them exposes it. And the torture could not see a stale generation until it learned to keep a node across rounds, as an open description does.

Step 3 (2026-10-01): done.

  • A regular file writes at its offset. On a filesystem with write_at a description is "direct": write goes to the node through a kernel page, read comes from it, and the 512-byte buffer that used to be the whole file is not involved. A filesystem that only stores whole files - FAT, until step 4 - keeps the old path: a file opened O_WRONLY or O_CREAT is replaced on release from what was written.
  • open's flags mean what Linux means. O_CREAT makes the file at open, with the mode given less the process's umask; O_EXCL refuses an existing name and does not follow a link there; O_TRUNC empties at open; O_APPEND finds the end at every write, so two appenders interleave; O_RDWR reads and writes; O_DIRECTORY and O_NOFOLLOW are honoured; an access mode of 3 is EINVAL. O_WRONLY without O_CREAT on a missing file is ENOENT - it used to make one.
  • File types gained four operations - pread, pwrite, truncate, sync
    • and their tables became designated initialisers. A type without positions is ESPIPE to the positional calls, as on Linux.
  • Twenty rows: pread64, preadv, sendfile, fsync, fdatasync, creat, umask, sync, syncfs, sync_file_range, readahead, fadvise64 and copy_file_range DONE; pwrite64, pwritev, truncate and ftruncate PARTIAL until FAT writes in place (step 4); preadv2/pwritev2 PARTIAL (RWF_ flags are refused, not ignored); fallocate PARTIAL (it guarantees the size, not the space, and only mode 0 and KEEP_SIZE). The registry stands at 75 done, 22 partial, 193 missing.
  • sendfile and copy_file_range copy in the kernel, through one page whose address goes where a user address usually goes: every file type copies through vibeos_uaccess_copy, which does not care whose memory it is. At most a megabyte a call, because a syscall runs with interrupts masked.
  • The umask is per process, beside the working directory, inherited by fork and exec.
  • Host tests run on a tmpfs the fake kernel mounts at /tmp, as the real one does: five groups, 70 checks - the flags, a 5000-byte write, the positional calls and their vector forms, truncation, durability, the advice calls, both kernel copies, and the whole-file filesystem still working. At boot the shell writes 820 bytes in twenty writes, appends five with >> and counts them with wc -c; the gate wants 825 (tmp_write_path_failed).
  • Sabotage: abi-write-path.txt (7) and abi-file-calls.txt (6), all red. Three went NOT RED first, and each was read before anything was changed:
    • ftruncate on a read-only descriptor was refused for the wrong reason. A read-only description was not "direct", so it fell into the whole-file branch - and its fstat and SEEK_END reported the size from when it was opened while another descriptor grew the file. A defect, fixed: "direct" is the filesystem's property, not the access mode's.
    • the whole-file writer taken for one that writes in place: the test only created new files, which take the create path; replacing an existing one is tested now.
    • umask keeping bits above 0777: the test never set one.
  • A check had stopped watching. check-user-access.py looked in kernel/abi/linux/ only; A3 moved read, write, getdents64, the console and the sockets to kernel/abi/files/, so the sites it was written for (M-050 to M-052) had been unwatched since. The case that hands read()'s user buffer to the filesystem went NOT RED, which is how it was found. It scans all of kernel/abi/ now: 53 casts, none raw.

Step 4 (2026-10-01): done.

  • The driver left the architecture first (kernel/fs/fat.c, include/vibeos/fat.h): it reaches the disk, its lock and the mount hook through registrations, so the host tests link the same file the kernel runs.
  • A file is its directory entry. The node id is where the 32-byte entry sits (sector and slot), which is the one thing FAT has that names a file without naming its path; every operation on a node reloads the entry from there and refuses a slot that no longer holds a file (ENOENT). The known gap is the one that follows from it: a description held across an unlink or a rename loses its file, and after the slot is reused it names another. FAT has no inode to keep; an in-memory one is a later step's work if a program needs it.
  • Writes in place: write_at grows the chain as needed and zeroes the gap when the offset is past the end - FAT has no holes and a cluster comes back from the allocator with whatever its last file left; truncate cuts the chain or grows it with zeros; a write that runs out of space keeps what fitted (the clusters that were left) and reports the short count.
  • Names: an entry is created with its long name whenever the name is not 8.3 upper case - the pieces, the checksum, a ~n alias that no other entry in the directory has - and an 8.3 name in lower case is stored with the two NT flags rather than a long name, as Windows does. A directory grows a cluster when it fills (the fixed FAT16 root cannot, and says ENOSPC), and a name that used up the end-of-directory marker writes a new one.
  • rename, rmdir, setattr, statfs, sync: rename writes the new name before removing the old, replaces an existing file (NOREPLACE honoured), refuses a directory moved into itself, and updates a moved directory's ".."; rmdir wants it empty; setattr keeps the read-only bit and the modification time, and refuses an owner other than root (EPERM) - FAT has nowhere to put one.
  • The whole-file path is gone. With no filesystem left that stores whole files, kernel/abi/files/regular.c lost the 512-byte buffer, the write-back on release and the "direct" flag; pwrite64, pwritev, truncate and ftruncate are DONE. The registry stands at 79 done, 18 partial, 193 missing.
  • Host tests (tests/kernel/fat_tests.c): a volume the driver formats in memory, filled with 0xE7 first so nothing is zero by luck - names of every kind, in-place writes, gaps, truncation, a directory grown to forty long names, rename in each of its cases, times, statfs, a volume filled to its last cluster, three hundred create/unlink cycles in the root.
  • Somebody else's reader. Those tests can only say the writer and the reader agree. scripts/dev/verify-fat-mtools.sh (in check.sh) has mformat make the volume and mcopy put a file on it, runs the driver over it (tests/kernel/fat_exercise.c), and compares what mdir and mcopy find with what the driver believes: names, contents, free space. mtools follows whatever the table says, so scripts/dev/fat-fsck.py checks what it cannot - lost and cross-linked clusters, chains against sizes, "..", orphaned long names - and is run on mformat's own volume first.
  • At boot the shell writes 866 bytes on the boot volume in twenty-two writes and counts them, writes and reads back a file under a long name (fat_write_path_failed), and cuts a two-cluster file with O_TRUNC. When the machine has stopped the gate runs fat-fsck on the image it left (boot_volume_inconsistent:<what>) and has mtools read the long-named file (mtools_cannot_read_what_the_guest_wrote).
  • Sabotage: fs-fat.txt (15, all red under the host tests or mtools, each case says which), fs-fat-boot.txt (2, red), abi-write-path.txt re-pointed (6, red). What went NOT RED, and why:
    • Under mtools, three cases at once, because mformat's volume was zeros: an unzeroed gap, an unzeroed directory cluster and a missing end marker all read as correct on a disk that is zero wherever nobody wrote. The image is filled with 'A' before mformat now, and fat-fsck has a --dirty mode that overwrites everything past each directory's marker.
    • An unzeroed directory cluster, still: with room left in the old cluster the marker stays there and the new cluster is never read. The exercise fills a directory exactly to the end of its cluster. And then the missing marker - written down at first as unverifiable here - turned out to be what had been hiding it.
    • The short write: the test's volume happened to have exactly the clusters the write needed. It checks its own arrangement now (0 < free < 4).
    • A sector that reaches the cache and not the disk, at boot: green, the volume consistent and the file readable. Recorded in fs-fat-boot.txt as something this environment cannot tell, with the likely reason.
  • Still open: timestamps are uptime, so every file is dated 1980-01-01 until there is a wall clock (L2); the exec page cache is not told about a write to a file it holds.

Step 5 (2026-10-01): done.

  • Thirty-two rows, twenty-nine in a new file, kernel/abi/linux/names.c, and three in fs.c: stat, lstat, statx; access, faccessat, faccessat2; rename, renameat, renameat2; rmdir and unlinkat's AT_REMOVEDIR; link, linkat, symlink, symlinkat, readlink; chmod, fchmod, fchmodat, fchmodat2; chown, fchown, lchown, fchownat; utime, utimes, futimesat, utimensat; statfs, fstatfs; mknod, mknodat; openat2. The registry stands at 108 done, 21 partial, 161 missing.
  • What Linux decides before a filesystem is asked is decided in the handler, so every filesystem answers alike: a rename or a link across two mounts is EXDEV, a mount's root is EBUSY to rename and rmdir, a directory goes only over a directory and never into itself, rmdir of "." is EINVAL and of ".." ENOTEMPTY (read off the string the program wrote - the walk has already resolved both), a hard link to a directory is EPERM, an empty symlink target ENOENT. What a filesystem cannot do it says itself through the wrappers - EROFS, or EPERM for a symbolic link or an owner on FAT - and nothing in the handlers asks which filesystem it is.
  • One user, root, until L2. No call refuses for want of permission, and access answers as it does for root on Linux: W_OK is EROFS on a filesystem that writes nothing, X_OK is EACCES on a regular file nobody may run.
  • st_dev is the mount. Every filesystem reported device 0, so a file in /tmp and one on the boot volume with the same inode number were one file to cp and mv, which compare the pair to refuse copying a file onto itself. A mount's place in the table, from 1; 0 is a pipe or a socket.
  • A descriptor's file is walked again from its path (linux_walk_fd): fchmod, fchown, futimens, fstatfs and every AT_EMPTY_PATH. The gap is the one a path for an identity always has - after a rename the description's path names nothing, or something else - and it is the same one step 4 recorded for FAT's nodes.
  • chown clears set-user-id on a regular file, and set-group-id when the file is group-executable, as Linux does even for root. mkdir passes its mode, less the umask, where it used to drop it. utimensat takes UTIME_NOW and UTIME_OMIT and, with no path, is futimens.
  • statx fills the basic set whatever was asked and says so in stx_mask; Linux has kept adding fields to the end of the structure, so only the part filled is declared field by field and the rest is "up to the size", compared as that (TAIL in the layout test). Six structures and twenty-four constants joined linux_layout.h; the layout test makes 366 comparisons.
  • Partial, and why: mknod/mknodat make regular files only - a FIFO or a device node is EPERM, because no filesystem here has anywhere to keep one and a name that looked like a FIFO and opened as an empty file would be worse; renameat2 refuses EXCHANGE and WHITEOUT (EINVAL, Linux's answer from a filesystem with neither); openat2 honours RESOLVE_NO_MAGICLINKS only - the others are ENOSYS, which a caller already handles by falling back to openat, where EINVAL would say its arguments were wrong.
  • Host tests: four groups in linux_abi_tests.c on tmpfs, on the fake kernel's root (which can neither rename nor link) and on a filesystem mounted for the purpose that writes nothing. At boot the shell runs mv, ln, ln -s, chmod, mkdir, rmdir and stat in /tmp and the gate reads stat's own line, META_600_2_2 (names_and_metadata_failed); then mv, mkdir and rmdir on the FAT volume (fat_rename_failed), which the gate's consistency check judges after the machine stops.
  • Sabotage: abi-names.txt (31), abi-names-fs.txt (6), abi-names-boot.txt (2) and two more in fs-fat-boot.txt, all red. Two went NOT RED first: RENAME_NOREPLACE left to the filesystem, and an empty symlink target accepted. tmpfs refuses both itself, so on tmpfs the handler's check could not be seen. What the check is for is a filesystem that cannot rename or link at all giving Linux's answer (EEXIST, ENOENT) and not its own (EPERM) - so both are asked of the fake's root now. A FAT rename that leaves the old name behind is invisible from inside the guest and red as boot_volume_inconsistent:cross_linked.
  • Still open: an open description does not follow its file across a rename; fchmod and fchown on a pipe or a socket are EINVAL where Linux changes an inode nobody can see; the remaining handler checks that tmpfs repeats (a file over a directory, a directory into itself) have no case of their own for the reason above.

Step 6 (2026-10-01): done.

  • Locks (kernel/fs/filelock.c, include/vibeos/filelock.h): one table, personality-neutral, of two kinds kept apart as Linux keeps them. Record locks are byte ranges held by an owner - a process for fcntl's F_SETLK, F_SETLKW and F_GETLK, an open file description for the F_OFD_ forms, both in one space so each sees the other's. An owner asking again replaces what it held: the table splits a lock whose middle is unlocked and joins two when the gap between them is filled, so an owner's locks are always disjoint and never two where one would do. Whole-file locks are flock's, held by the description. The table never waits and never allocates; it refuses with the owner in the way, and checks it has room for the worst case before it changes anything.
  • The handlers do the waiting, the way a pipe's reader waits, and stop for a signal. Before a process waits for a record lock the table is asked whether the holder is, through however many others, waiting for it: EDEADLK instead of two waits for ever. Linux looks for no deadlock among description-owned locks, and neither does this.
  • Lifetimes, which are the hard part of POSIX's locks: closing any descriptor a process has for a file gives back every record lock the process holds on it; a process's locks end at its exit and are not inherited by fork; a description's locks - flock, OFD - go when the description does, so a dup and a fork share them. A lock at SEEK_END is where the file ends when it is taken; a length of 0 runs to the end wherever that goes; a negative one is the bytes before the start.
  • Directories: the file layer hands out entries by position (vibeos_dirent_t, the readdir operation that replaced getdents), and the record is the personality's - so getdents64 and the older getdents are two formats over one reader, and a Windows directory query will be a third. Names are whole (they were cut at fifteen bytes), "." and ".." are in every directory once, each entry carries the inode number stat reports and its real type (a symbolic link was listed as a file, and a program that walks a tree trusts that), and the position is the description's own, so lseek(fd, 0) rewinds and a d_off can be returned to.
  • Extended attributes: the twelve calls say what Linux says of a filesystem that stores none - EOPNOTSUPP to setting, getting and removing, an empty list - after refusing what Linux refuses first, in its order: a flag, a name (ERANGE), a size (E2BIG), and the file itself, so a mistyped path is ENOENT and not "not supported".
  • close_range's UNSHARE asks for nothing in a process of one thread and is honoured there. With threads it is still refused: a thread cannot hold a table apart from its process until L6, which owns that row now.
  • Sixteen rows closed: flock, getdents, the twelve xattr calls, and fcntl and getdents64 from partial to done. The registry stands at 124 done, 19 partial, 147 missing.
  • struct linux_dirent, the old call's record, is declared by nobody - Linux keeps it to itself and no C library has used the call in years. A fixture this project wrote would only agree with the declaration this project wrote, so the layout test asks the host's kernel to list "/" with that call and reads the answer through our structure: the records have to chain, and "." and ".." have to be there with an inode and a directory's type in the last byte.
  • Host tests: the table on its own (filelock_tests.c), four groups of handlers in linux_abi_tests.c, and a torture in the nightly (filelock_torture.c, 300 seeds sanitized, three seeds in check.sh and in every host sabotage run). Its model has no ranges: a file is seventeen cells, an owner paints them, and every round compares the answer, a probe of every cell for every owner, and the number of locks held against the number of runs of equal cells - one more is a join that was missed.
  • At boot the ring-3 self-test takes a record lock and a flock, forks, and the child finds both in its way (file_locks_selftest_failed) - nothing else the boot runs takes a lock, and the BusyBox on the image has no flock applet; the shell lists a directory holding a long name and a dangling link, and the gate reads ls's own line and DIRS_4_1 (directory_listing_failed).
  • Sabotage: fs-filelock.txt (15), abi-locks.txt (20), abi-readdir.txt (5), abi-xattr.txt (6) and one more in fs-file.txt on the host; abi-locks-boot.txt (2) and abi-readdir-boot.txt (2) at boot. All red, each for the expectation it names. One went NOT RED first: a pipe that cannot be told from another pipe. The test locked one pipe, and one of anything has an identity whatever it is computed from; it locks two now, and the other end of the first.
  • A new must-be-zero, filelock_unlocked: the table used with no lock registered, as the mount table counts it.
  • Still open: an exec keeps every record lock the process holds, those on its close-on-exec descriptors included, where POSIX gives those back as the descriptors close; a lock is found by the filesystem's identity for the file, so on FAT it follows the directory entry (step 4's gap); mandatory locking does not exist, as on current Linux.

Step 7 (2026-10-01): done.

  • The console is a terminal (include/vibeos/tty.h, kernel/abi/files/console.c). It was a character device that answered ENOTTY to every terminal question but the foreground process group - by design, and truthfully - read a line at a time, echoed always, and handed back whatever had been typed when the keyboard went quiet, half a line included. It has modes now, one set for the one terminal, in Linux's numbering as the open flags are; another personality translates its console modes into them.
  • What is honoured: ICANON - a read returns one finished line, erasable until Enter, and waits for it; without it a read returns as soon as MIN bytes are typed, with MIN 0 at once. ECHO, ECHOE, ECHONL. The erase, kill and end-of-file characters. ICRNL on input; OPOST with ONLCR on output. Typed- ahead input is taken from the keyboard only as far as a read needs it, so what comes next is read in whatever mode the terminal is in by then. Everything else is stored and given back as set - which is what lets a program save the modes and restore them - and no more: the interrupt character interrupts with ISIG clear, and TIME is not a timer.
  • The requests are the personality's, as their structures are: TCGETS, TCSETS, TCSETSW, TCSETSF, TIOCGWINSZ and TIOCSWINSZ on the console, ENOTTY on anything else; and four any descriptor answers - FIOCLEX, FIONCLEX, FIONBIO, FIONREAD (a file, a pipe, the terminal). ioctl stays PARTIAL for what is left: ISIG, TIME, TCFLSH, TIOCSCTTY and the rest.
  • A terminal changes what every program does, and the boot found out at once. A C library buffers a terminal by lines and writes a line as two pieces of one writev, which this kernel wrote as two writes: THREADS_C5_EXEC_OK arrived as THREADS_C5_EXEC and _OK with a log prefix each, and five gate assertions failed on output that was correct. A writev that fits a page is gathered and written once, as Linux writes it. ls lays its names out in columns for the width TIOCGWINSZ reports.
  • And a shell on a terminal is interactive. BusyBox's ash prints a prompt, puts the terminal in raw mode and edits its own command line, asking poll() before every key - a syscall this kernel did not have, so the first boot printed "poll: Function not implemented" once per character of the self-test. +i does not turn that off in BusyBox. So poll is here ahead of L4, as much of it as the terminal needs: each file type says what it can do without waiting (ready: the console, a pipe; a file is always ready), and the wait is the pipes' wait. PARTIAL, owned by L4: a socket always reports ready. The self-test's shell session is a real interactive one now, every command typed a key at a time through raw mode.
  • A defect three steps old, found by reading the log this step changed. The native shell's write opened its file with O_CREAT alone, which is read-only. That worked while a file was replaced on release from whatever had been written; from step 4, when a write went into the file at once, the write was refused, nothing looked at its result, the shell printed "written", and the file was empty. Every cat DOCS/NOTES.TXT in the boot - five of them, four programs - printed nothing for three steps, and the gate stayed green: the line "persistent hello" moved the gate's reported phase and was asserted by nothing. It is asserted now (notes_file_not_read_back, five times), the shell opens to write and checks what write returns, and the sabotage case is the defect as it shipped.
  • Host tests: the fake kernel has a keyboard that can be typed at (kf_type); two groups cover the modes and the requests, poll and the gathered writev. At boot: the ring-3 self-test asks TCGETS and expects a canonical, echoing terminal (it used to expect ENOTTY); the shell's prompt (shell_not_interactive); and the word "echo" arriving as four writes of one character - the shell showing each key itself, the kernel's echo off (terminal_raw_mode_failed).
  • Sabotage: abi-tty.txt (15), abi-tty-fs.txt (13), abi-tty-pipe.txt (2) on the host; abi-tty-boot.txt (2), abi-tty-console-boot.txt (1) and user-sh-boot.txt (1) at boot. Three host cases went NOT RED first, and each was the test's:
    • poll waits with a timeout of 0. The fake kernel abandons a call that waits, and an abandoned call leaves 0 behind - which is what poll returns when nothing is ready. The test compared the number. It asks now whether the call returned at all.
    • poll reports reading to whoever asked about writing. Nothing asked about writing on a descriptor that could be read.
    • a read returns every finished line at once. The terminal takes input only until one line is finished, so two never waited together - except after raw mode, where FIONREAD takes everything typed. That is the arrangement the test makes now.
  • Still open: ISIG and TIME; TCSETSF throws away the terminal's own line and not what is still in the keyboard's queue behind it; there is one terminal and no /dev/tty to name it; a program that dies in raw mode leaves the next one in it, as on a real terminal, and nothing here resets it.

Step 8 (2026-10-01): done, except LTP, which waits for L3.

  • The corpus runs on VibeOS and is compared with Linux. tests/corpus/run-l1.sh runs fifteen workloads - twelve of BusyBox's (the shell, ls, cp/mv/rm, find and grep, grep -r, tar, sort/sed/awk, sed -i, touch/chmod/ln/stat, mkdir/rmdir, a pipeline, mv), SQLite on a file and in memory, and a Lua script - and prints every line of every answer tagged with the workload's name. scripts/dev/corpus-expect.sh runs the same script under the same BusyBox on Linux and keeps what it printed (tests/corpus/l1-expected.txt, 100 lines). The boot gate stages the script on the boot volume, the self-test's shell runs it in /tmp, and the gate compares the two line for line: corpus_answers_differ:<workloads>, corpus_did_not_finish. All fifteen match.
  • The oracle is Linux. Nobody wrote down what ls -la or tar tf should print; the commands are shaped so that what two machines may differ in - the date, the owner's name, a directory's size, the order it lists in - is not in the answer, and everything left has to be identical. The nightly re-runs the script on its own Linux first (corpus-expect.sh --check), so a BusyBox that starts answering differently is named before the boot is compared with a stale file.
  • SQLite and Lua are staged when corpus-build.sh has built them, which a plain build does not do. Without them their workloads answer "absent", which the gate accepts and says (corpus_absent= on the verdict line); with VIBEOS_SMOKE_CORPUS=require, which the nightly's corpus job sets, absent is a failure. sqlite3 makes a database, indexes it, vacuums it, and a second process reopens it and finds the first one's rows - record locks, pread and pwrite, fsync, truncate - with no syscall missing.
  • What running real programs found, none of it in the syscalls this step set out to prove:
    • execve of /proc/self/exe. BusyBox's shell runs every applet not built into it by executing "the program I am" under the applet's name. That was refused as not found, the shell fell back to a file called after the applet on its PATH, and four such files had been staged - which is why cat and ls worked and sort, sed, tar and awk never could have. readlink has answered for that name since A4; execve does now.
    • A forked child did not know what program it was. Fork copied the image's entry point and nothing else of it, so the path it was started from - what /proc/self/exe answers - was the slot's last tenant's. With the fix above the shell's child executed "itself" and started the thread tests, which forked children that did the same: the boot did not end. The whole image is copied now.
    • The user stack was sixteen kilobytes and did not grow. BusyBox's sed has a function with an 18 KiB frame; every workload prints through sed, so every workload ended in a segmentation fault. 256 KiB, mapped at exec. Growth on demand is L3's.
    • Files on FAT were not executable. Reported 0644 where Linux reports a FAT volume's files 0755; exec never asked, and the script's [ -x ] did - so SQLite and Lua were on the volume, judged not runnable, and reported absent. This one was only visible because "absent" is printed in the verdict: the boot was green.
    • /dev/null does not exist (L2, with /proc and /etc); the script does not use it.
  • LTP is staged and run, and does not run yet. scripts/dev/ltp-run.sh stages any of the 1,530 built tests on the boot volume, runs them after the workloads and reads each one's verdict out of the log. Every one is "broken" before its first check: LTP's harness keeps its results in a page shared between the test and the child that runs it, mapped from a file - mmap(MAP_SHARED) of a descriptor - and file-backed mappings are L3's (as is keeping a shared page shared across fork). None of the built tests uses LTP's older harness, which did not need it. So L1's 416 tests are an oracle that exists and cannot be consulted until L3; what proves L1 until then is the corpus above and the handlers' host tests. Recorded under L3, which now owes L1 that run.
  • Sabotage: five cases at boot, all red. execve refusing /proc/self/exe is corpus_did_not_finish - the shell cannot start sh itself; a rename that reports success and does nothing is corpus_answers_differ:bb-cp-mv-rm+bb-sed-inplace+bb-mv, exactly the three workloads that move files; fork copying only the entry point is a boot that never ends; a four-page stack is 23 ring-3 faults; a self-test that does not run the script is corpus_did_not_finish.
  • One boot in eighteen failed, with M-070's signature - the open swap-slot double release under the reclaim load - and for the first time with that finding's instrumentation in the log: init killed on a swapped-out entry whose slot was already free when its address space was torn down. Not this step's defect and not chased here; the evidence and what it narrows are in the tracker under M-070. What this step may have changed is how often: sixty more anonymous pages per process is sixty more for reclaim to take. Twelve boots of twelve were clean afterwards.

L1 is closed as far as it can be without L3: the registry stands at 124 done, 20 partial, 146 missing; 23 of the corpus's 25 workloads have nothing missing, and fifteen of them are run and compared on every boot. Open, and written where they belong: LTP (L3); mknod of a FIFO, renameat2's EXCHANGE, openat2's RESOLVE flags, ioctl's ISIG and TIME (L1 partials nothing has asked for yet); a description that does not follow its file across a rename.

L2. Processes, credentials and time (47)

Sleeping and timers (nanosleep, clock_nanosleep, alarm, setitimer, POSIX timers), resource limits and usage, waitid, process groups and sessions completed, the rest of signals (sigaltstack, rt_sigsuspend, rt_sigtimedwait, rt_sigqueueinfo, SA_SIGINFO with a real siginfo_t), execveat, clone3, pidfds.

The credential model arrives here: real, effective, saved and filesystem ids, supplementary groups, and permission checks in L1's file operations. Setting the clock stays EPERM until there is a privilege to allow it.

A minimal /proc (/proc/self/exe, /proc/self/fd, /proc/self/maps, /proc/meminfo, /proc/cpuinfo) belongs here as well: it is a filesystem, not a syscall, but ps, top and most C libraries read it.

Programs: BusyBox sh scripts with trap, timeout, sleep, ps, time; LTP's signal and process cases.

L2 was taken after L3, because L3's oracle said what it wanted most: of the LTP tests for L1 that did not pass, most stopped at something of L2's - a credential call, alarm, a file under /proc or /dev. Seven steps, in the order that pays that debt first:

  1. Credentials: who a process is, the calls that change it, and permission checks in L1's file operations.
  2. Signals completed: a fault delivered to a handler that asked for it (SIGSEGV, SIGBUS, SIGFPE, SIGILL) with a real siginfo_t, sigaltstack, rt_sigsuspend, rt_sigpending, rt_sigtimedwait, the queueing calls, pause, and SIGCHLD raised when a child ends.
  3. Timers: alarm, setitimer, POSIX timers, clock_getres, gettimeofday, times - which is also LTP's own timeout. Setting the clock stays EPERM.
  4. Limits and usage: getrlimit/setrlimit/prlimit64 enforced, getrusage, priorities, personality.
  5. Processes: waitid, getpgid, execveat, clone3, pidfds.
  6. /proc and /dev: /proc/self/*, /proc/<pid>, /proc/cpuinfo, /proc/mounts; /dev/null, /dev/zero, /dev/urandom, /dev/tty.
  7. The programs: the corpus's shell workloads with trap, timeout, ps, time; LTP for L2's own calls.

(nanosleep and clock_nanosleep were done in L3 step 3, which could not run its oracle without them.)

Step 1 (2026-10-02): done.

  • A process is somebody. Real, effective, saved and filesystem ids for user and group, and thirty-two supplementary groups, in the process's state (include/vibeos/cred.h); all zero - root - for a process nobody changed, a copy for a forked child, kept across exec. The rules for changing them are Linux's without capabilities ("privileged" is effective user 0) and live in one portable file of pure functions, kernel/sched/cred.c.
  • Sixteen calls: getuid, geteuid, getgid, getegid report them (they answered 0); setuid, setgid, setreuid, setregid, setresuid, setresgid, getresuid, getresgid, setfsuid, setfsgid, getgroups, setgroups. setuid(1000) used to be EPERM: there was one identity.
  • The file operations ask who is asking. The walk needs search permission on every directory it looks a name up in; open needs read or write by its flags (and write to truncate - asked before anything is cut); a name is made or removed only in a directory the caller may write, and in a sticky one (/tmp) only by the file's owner or the directory's; chmod and a chosen time are the owner's, chown the superuser's except for the owner's own groups; truncate, chdir and execve ask for write, search and execute. What a process makes is its own. access answers for the real user and AT_EACCESS for the effective one. The superuser is refused nothing but running a file with no execute bit at all - and for it nothing more is looked up than before.
  • Signals: a process may signal another only if it is the superuser's or the target's real or saved user is its real or effective one - on top of the session rule already there.
  • Not done, and said: capabilities; the set-user-id bit on exec; a new file taking its directory's group under a set-group-id directory; setgroups with more than thirty-two groups (the registry's gap); FAT has no owners, so everything on the boot volume is root's and nobody else may write there.
  • At boot the self-test's child gives up root for good and is refused root's file, root's directory and root's name in /tmp, owns what it makes, and cannot take root back (credentials_selftest_failed).
  • Sabotage: sched-cred.txt (8), abi-cred.txt (8), abi-cred-names.txt (6) against the host tests, abi-cred-boot.txt and abi-cred-exec.txt against the boot; all red. One went NOT RED first - "the owner's bits do not decide for the owner" - because no test had an owner whose own bits were the stricter ones; there is one now. And the boot's own case went NOT RED: the child was refused creating a file in root's directory, which the directory's write bit refuses with or without the walk's search check; it reads a file anybody may read there now.
  • Found by a failed boot, not by a test: exec built the new process state without the credentials, so a program was whoever had held the slot before - one boot in six, root's shell was the self-test's user 1000 and every create on the boot volume was "Permission denied". exec copies them, a new slot starts as root, and the self-test's child now runs BusyBox test -r on root's file and must see it refused (abi-cred-exec.txt, red). Nine boots of nine after the fix.
  • LTP, L1's 382 again: 154 passed (131 before it), 16 failed, 144 broken, 65 not applicable, 2 did not run. What is left is mostly a loop device, alarm, and files under /proc and /dev.

Step 2 (2026-10-03): done.

  • The frame is Linux's. A handler is entered with the signal, a siginfo_t and a ucontext - the interrupted registers by Linux's names, the mask to return to, the alternate stack - and the vector registers in an FXSAVE area that fpstate points at. rt_sigreturn reads the frame back as the program left it, so a handler that moves the saved rip moves where the program resumes; the architecture still decides what of it may be trusted (ks_regs_set: selectors and privileged flags forced, a resume address outside user memory refused, H-018). Until this step the frame was private to the kernel - a magic, the mask, the raw trap frame - which served a handler that only returns, and nothing that asks why or where. The registers cross the boundary by name (vibeos_uregs_t), the frame's layout is the Linux layer's, and the layouts are compared with the host's headers.
  • Why a signal came travels with it (include/vibeos/siginfo.h): who sent it, which fault, how a child ended - facts, which the Linux layer puts into si_code and the rest. Raising and taking are one critical section each (ks_signal_send, ks_signal_take), under a lock per task with interrupts off. A blocked signal is kept even when ignored, as Linux keeps it, so sigtimedwait can take it.
  • A fault goes to a handler that asked for it. SIGSEGV, SIGBUS, SIGFPE and SIGILL with si_addr and the right si_code, the trap number, error code and cr2 in the frame; blocked or ignored, it kills as before. Nothing about a fault the program takes is printed as a trap: [SIG] fault taken by the program's handler. The null page of a Linux program is present - the kernel's identity map lies under the low window - so the CPU says "protection" where Linux says "nothing mapped"; the program is told whether it had a mapping.
  • Calls: sigaltstack (with SS_AUTODISARM), rt_sigpending, rt_sigsuspend (the handler returns to the program's own mask, not the temporary one), pause, rt_sigtimedwait, rt_sigqueueinfo, rt_tgsigqueueinfo; SA_ONSTACK, SA_NODEFER, SA_RESETHAND honoured.
  • SIGCHLD is raised when a child ends, with its pid, uid, status and how it ended. It never was.
  • Not done, and said: a real-time signal queued twice is delivered once (the registry's gap on 129 and 297); signals sent to a process go to its leader rather than to any thread that does not block them; SIGCHLD on stop and continue, SA_NOCLDWAIT, and a parent that ignores SIGCHLD reaping its children automatically.
  • At boot the musl program that already checked handlers now also recovers from a fault on its alternate stack by moving the saved rip, keeps a vector register across a handler that overwrites it, reads sigqueue's value, takes signals with sigtimedwait, waits in sigsuspend and pause, and is told of its child's end by SIGCHLD (signal_l2_checks_failed).
  • Sabotage: abi-signals.txt (10) and abi-signal-calls.txt (8) against the host tests; abi-signals-boot.txt (3), abi-signals-fpu.txt and abi-signals-value.txt against the boot; all red. One went NOT RED first - a fault the program blocks offered to it anyway - and correctly: delivery skips a blocked signal and the task is killed either way; what the check keeps from happening is a signal left pending in a task about to die, which the test now asks about. Nine boots of nine after it.
  • LTP: not measured in this step. The first test of the signal list, pause01, waits for its child to show as sleeping in /proc//stat (step 6), and LTP's own timeout is setitimer (step 3), so the first test that waits forever takes the rest of its boot: 3 of 48 reported (kill08 passed; kill05 needs System V shared memory; kill06 failed with ESRCH killing a process group, not yet looked into). Measured again with step 3, when the timeout works.

Step 3 (2026-10-03): done.

  • Timers that belong to a process (kernel/sched/ptimer.c, portable, host-tested): one table for the machine with a lock of its own, armed by the process's calls on any core, counted down by every core's tick and fired by the clock owner's. The callback that raises the signal runs outside the table's lock, because it takes the scheduler's; and a timer whose process is gone is counted (ptimer_orphan, must be zero) rather than fired at whoever has the pid next. The exit path takes a process's timers with it; exec takes its POSIX timers and leaves alarm, as Linux does.
  • Calls: alarm, setitimer, getitimer (real, virtual and profiling); timer_create, timer_settime, timer_gettime, timer_getoverrun, timer_delete on the machine's clocks and on the process's and a thread's CPU time, with SIGEV_SIGNAL, SIGEV_NONE and SIGEV_THREAD_ID; clock_getres, gettimeofday, times; clock_gettime on the CPU-time clocks. clock_gettime used to answer every clock number with uptime, so a program asking for its CPU time got the wall clock; an unknown clock is EINVAL now. Setting the clock is REFUSED (EPERM).
  • What the tick can tell: a tick is the resolution, and clock_getres says so. CPU time is charged by the tick to whatever each core was running, as user time when it interrupted ring 3 - which is what ITIMER_VIRTUAL needs. A timer never fires early: it is armed a tick late, and that tick is not reported back.
  • Overruns are Linux's: an expiry that finds its timer's signal still pending adds to that signal's count, which si_overrun and timer_getoverrun report; periods that went by with no tick to fire them count too.
  • What LTP found once its timeout worked, none of it in the timer code: a thread's id did not name its process for kill and rt_sigqueueinfo; a signal to a process already exiting was EINVAL instead of 0; tgkill looked up ids that are not positive; waitpid(INT_MIN) was ECHILD, not ESRCH; SA_RESETHAND cleared SA_SIGINFO; rt_sigaction and rt_sigprocmask accepted a sigset of any size; and getpgid did not exist - musl's getpgrp() is getpgid(0), so every musl program's process group was -ENOSYS (taken early from step 5). And the CPU time of a process in a recycled slot included everybody who had held the slot: the scheduler's accounting is per slot, so each task now records where it started (cpu_base). signal06 signals itself thirty thousand times and the console line per signal ran its boot out of time; only the first 64 are printed now.
  • Not a kernel defect: LTP's x86-64 rt_sigaction wrapper fetches its restorer from the C library's sigaction old action, and musl does not fill that field, so rt_sigsuspend01 and rt_sigaction01 return through an uninitialised pointer. Built against glibc they would not.
  • Not done, and said: times() reports no system time, and a thread that has exited takes its CPU time with it (the registry's gap on 100); the CPU clocks of another process (negative clock ids); SIGEV_THREAD is the C library's and reaches the kernel as a signal to the process. Signal 64 has no bit in a 64-bit mask numbered by signal (sighold02, sigrelse01). clock_settime stays refused, and four LTP tests say root should be able to set the clock: an offset to the timer's uptime would do it, the day a program needs it.
  • At boot the musl signal program waits in pause for alarm(1), counts four expiries of a periodic setitimer, spins until ITIMER_VIRTUAL and ITIMER_PROF fire, takes a POSIX timer's signal with sigtimedwait and checks SI_TIMER and its value, waits for a timer on its own CPU time, uses SIGEV_THREAD_ID, and reads the clocks (timers_did_not_fire); a child that arms an alarm and exits leaves nothing behind (ptimer_orphan).
  • Sabotage: sched-ptimer.txt (10), abi-timers.txt (9) and abi-cpu-base.txt against the host tests, with four more in abi-signal-calls.txt and one in abi-signals.txt for what LTP found; abi-timers-boot.txt (2) and abi-timers-tick.txt against the boot; all red. Two went NOT RED first, both tests right about the outcome and wrong about the arrangement: the wall clock and the process's CPU clock both read seven ticks, since the process had done nothing but run; and a bad flag was tried together with a time that was not one. Nine boots of nine after it.
  • LTP: the timer tests and step 2's signal tests together (tests/corpus/ltp-l2.txt's subset, 70 tests): 37 passed, 1 failed (times03: no system time), 24 broken, 7 did not run (kill10 takes the rest of its boot: step 5). Before this step the same list ran 3 of 48. Of the broken: /proc//stat and /proc/cpuinfo (step 6), setrlimit (step 4), clock_settime refused, System V shared memory, signal 64, and LTP's musl restorer.

Step 4 (2026-10-03): done.

  • Limits (kernel/abi/linux/limits.c): Linux's sixteen, soft and hard, in the process state - a child has its parent's, exec keeps them, and the first process starts with Linux's defaults where this machine can honour them (linux_procstate_defaults, called by the architecture's hw_procstate_new: the third writer every field of the process state has). getrlimit, setrlimit and prlimit64 (on another process too, for the superuser or a caller whose ids match) with Linux's rules: soft above hard is EINVAL, raising a hard limit is the superuser's, NOFILE stops at what the descriptor table holds.
  • Enforced where the mechanism already was: NOFILE is the descriptor table's own limit; FSIZE cuts a write at the limit in the one place every regular-file write passes, and past it is SIGXFSZ and EFBIG; DATA stops brk; NPROC refuses fork and clone for a user who is not root; CPU is two timers in the process-timer table (step 3) on the process's CPU time - SIGXCPU at the soft limit and every second after, SIGKILL at the hard one. The rest are kept and reported, and the registry says so.
  • getrusage reports CPU time (all of it user time); getpriority and setpriority are the scheduler's nice, answering 20 - nice raw as Linux does, with Linux's rules for who may renice whom and RLIMIT_NICE's allowance; personality is kept and reported, and changes nothing.
  • Found on the way, and not in this step's code: the scheduler's policy had never run on the machine (M-084). kernel/sched/sched_policy.c - classes, nice-weighted fairness, affinity - was written on 2026-09-02, host-tested and tortured, and the architecture admitted every task into it and asked it to pick on every tick. It never initialised it. Every admission into a table of no slots was refused, every refusal was ignored, the picker returned nothing and the machine fell back to round robin; nor was the policy ever charged the virtual time it picks by, so initialising it alone would have picked the lowest slot for ever. setpriority returned 0 and changed nothing, which is how it was found. The architecture initialises it and charges it on every tick now; a refused admission is sched_admit_refused, must be zero, and the gate asserts the policy was charged (sched_policy_never_charged).
  • Not done, and said: RSS, AS, MEMLOCK, STACK and the rest are kept, not enforced (RLIMIT_AS is the registry's gap on 160 and 302); getrusage has no resident-set high-water mark (98); no personality flag changes behaviour (135).
  • At boot SIGNAL.ELF runs into NOFILE, FSIZE (dies of SIGXFSZ, and with it ignored gets EFBIG) and CPU (SIGXCPU after a second of running), and checks priorities, getrusage and personality (limits_not_enforced).
  • Sabotage: abi-limits.txt (12), abi-limits-paths.txt (2), abi-limits-mm.txt and abi-limits-fork.txt (3) against the host tests, abi-limits-boot.txt and sched-policy-live.txt (2) against the boot; all red. Eighteen boots of eighteen after it - double the usual, because the scheduler's policy going live changes every program's scheduling.
  • LTP: the limit tests (22): 17 passed; the rest need 512 MB free (getrusage03), /proc/cpuinfo (getrusage04, step 6), sched_getaffinity (nice05, L6), select (personality02), /bin/true on the volume (setrlimit04), and useradd (setpriority01, which then waits out its timeout - not looked into). With step 3's 70 alongside: 54 of 94 passed.

Step 5 (2026-10-03): done.

  • One way to wait. wait4 and waitid share one engine, which selects every child, one by pid, or a process group's. wait4 used to hear only "any" and "this pid": waitpid(0) and waitpid(-pgid) - a shell's job control - were matched as pids and answered ECHILD. wait4 fills its rusage now. waitid reports what ended as a siginfo (CLD_EXITED or CLD_KILLED, the pid, the user it was when it ended, the status), honours WNOWAIT - look without reaping - and WNOHANG with a record of zeroes, and takes a pidfd as P_PIDFD.
  • Pidfds (kernel/abi/files/pidfd.c): a description that names a process by its pid and the tenancy of its slot, so that a pid reused after the reap is not mistaken for it - the reason a program holds one. Readable once the process has ended. pidfd_open (a process, not a thread; always close-on-exec) and pidfd_send_signal (kill, or sigqueueinfo with a siginfo; ESRCH once the process is reaped, whoever has the number now). It is what a Windows process handle is, which is why it lives with the file types and not in the Linux layer.
  • clone3 is clone's two paths with the arguments in a structure - a thread, or a fork - plus CLONE_PIDFD; execveat runs a program relative to a directory descriptor, or the file a descriptor names with AT_EMPTY_PATH, and refuses a link under AT_SYMLINK_NOFOLLOW.
  • Not done, and said: stopped and continued children are never reported (waitid's gap); clone3, like clone, makes no process that shares its parent's memory and takes no chosen pid or cgroup (its gap).
  • At boot SIGNAL.ELF looks at a child with WNOWAIT and then reaps it, waits for a group, signals a child through a pidfd, sees the pidfd become readable and waits for it with P_PIDFD, runs BusyBox through execveat on a descriptor, and forks with clone3 and CLONE_PIDFD (process_calls_failed).
  • Sabotage: abi-processes.txt (6), abi-pidfd.txt and abi-pidfd-calls.txt (3) against the host tests, abi-processes-boot.txt against the boot; all red. Six boots after it: 6 of 6 clean.
  • LTP: the step's own 41 (build-gcc-Release/ltp-l2s5.txt): 12 passed, 2 failed, 26 broken, 1 not applicable. It found two defects in this step's clone3 - the exit signal a caller names is ignored and SIGCHLD sent instead (clone301), and five of the argument checks Linux makes are missing (clone302) - and two in older layers that decide most of the rest. Eight tests waited out their timeout; waitid04, the one read, passed its checks and then hung in LTP's checkpoint, a futex shared between processes through a MAP_SHARED page, which the futex table keys per process. And an orphan is never given to init here, so the children of a test killed by its timeout stay zombies nobody reaps: the task table filled, and every fork after that was EAGAIN (seven tests). All three are fixed in the next commits. The rest want /proc (step 6), unshare, cgroups, a loop device, core dumps, ns_last_pid, or LTP resources that are not staged (execveat01, execveat02).

Step 6 (2026-10-04): done.

  • /proc is about processes (kernel/fs/procfs.c, rewritten). self is a link to the asking process's directory, and each process has one: stat (Linux's 52 fields), statm, status, cmdline, comm, maps, mounts, the links exe, cwd and root, and fd/, a link per descriptor to what it was opened as. The machine has cpuinfo (CPUID under Linux's names, a clock measured from the TSC, the brand that tells LTP it is virtualised), mounts, version, uptime and loadavg. The filesystem formats and keeps nothing; what it says about processes it asks of a source in the Linux layer (kernel/abi/linux/procsrc.c), which copies it out under the locks that hold it. A file is generated straight into the reader's buffer at any offset, never whole on a kernel stack.
  • /proc/self/exe is a walk. execve and readlink recognised the name by its spelling, because there was no /proc to hold it; both special cases are gone, deleted rather than generalised.
  • A process that waits shows S. Every wait here keeps its task runnable, so the task's state could not say; a wait's block point marks it and the dispatcher clears the mark when the call returns. LTP's harness waits to see exactly that before it signals a child that pauses.
  • /dev (kernel/fs/devfs.c): null, zero, full, random, urandom, tty, console, and the links fd, stdin, stdout, stderr. A node is a type and a number; what it opens as is decided by the number in the file types (kernel/abi/files/chrdev.c), and /dev/tty is the console's own description, which answers as a terminal. stat reports the device number.
  • Random numbers (kernel/core/random.c): a ChaCha20 key everything is mixed into and every read replaces, tested against RFC 8439's vector. Fed by RDRAND when the processor has it and by every core's timer interrupt - ready, under QEMU's TCG processor which has no RDRAND, within a second of the timer starting. getrandom waits until then (EAGAIN with GRND_NONBLOCK, what there is with GRND_INSECURE); it answered ENOSYS before, under a registry row that said done. /dev/random waits, /dev/urandom does not.
  • What step 5's LTP run found, fixed. A futex word on a MAP_SHARED page is the page's, the same word in every process that maps it - LTP's checkpoints - and FUTEX_WAIT's timeout is honoured; both had been missing since L3 made shared mappings, under a comment that said there were none. A process's children go to init when the last of its tasks ends: none ever did, so every child of a killed test stayed a zombie holding its slot. clone and clone3 send the exit signal they were given, and clone3 checks what Linux checks. And exit clears a task's pointer to its process state under the scheduler's lock before giving its reference back, so whoever finds the state under that lock - /proc now, prlimit before it - can hold it.
  • At boot SIGNAL.ELF reads its own /proc files, sees a paused child as S, uses /dev/null, zero, full and urandom and getrandom, waits on a futex in a shared file page from another process, is sent SIGUSR2 by a child cloned with it, and sees an orphan adopted (proc_dev_checks_failed).
  • Sabotage: core-random.txt (4), fs-procfs.txt (6), fs-devfs.txt, abi-chrdev.txt, abi-futex-shared.txt and abi-clone3-checks.txt (3 each) against the host tests, abi-procdev-boot.txt against the boot; all red. The pool's key not being replaced after a read is not a case, and the case file says why: nothing a caller can see changes. Six boots after it: 6 of 6 clean.
  • LTP: the step's own tests with step 5's again (build-gcc-Release/ltp-l2s6.txt, 79): 41 passed, 9 failed, 21 broken, 4 not applicable, 4 did not run (kill10 still takes the rest of its boot). Step 5's 41 among them: 23 passed, 12 before. What it found, for step 7: a process group left orphaned with stopped members is never sent SIGHUP and SIGCONT, as POSIX says it must be - waitpid13's child moves half its children into a group of its own, they stop themselves, LTP's kill of the test's group does not reach them, and they hold their slots until a fork is EAGAIN (pause01); /proc/<pid>/task, which futex_wait03 reads to see its thread asleep and waits out its timeout without; a /proc/<pid> directory used as a pidfd (pidfd_send_signal01, 02); /dev/random's ioctls (ioctl07); and one clone3 check more (clone302's invalid pidfd). Capacity, not defects: waitpid03 forks 25 children and futex_wake02 clones more threads than a process may have, on a table of 32 slots. The rest want stopped and continued children reported (waitid07, waitid08, waitpid08, waitpid13), core dumps, /proc/sys, a loop device, mknod of a device, unshare and cgroups.

Step 7 (2026-10-04): done. L2 is closed, with its gaps named.

  • Eight workloads more in the corpus, twenty-six in all, compared line for line with what Linux printed under the same BusyBox: bb-trap (traps a shell takes from signals it sends itself, an exit trap's code), bb-kill-wait (children ended by TERM and KILL, wait woken by a kill, jobs), bb-timeout, bb-time (time -p, its CPU time read from wait4's rusage), bb-ps (a sleeping child and its parent by state and group), bb-proc, bb-dev (device numbers and modes, the links into /proc/self/fd, /dev/full's ENOSPC) and bb-limits (ulimit, a file-size limit run into, renice). Six of eight matched on the first boot. Of the other two, one was the workload's: nice is not one of this BusyBox's applets, and Linux had answered from coreutils' - it uses renice now. The other was the kernel's.
  • kill had a rule Linux does not have. A signal to another session was refused, a rule from before processes had owners. BusyBox's timeout leaves a watcher in a session of its own (bb_daemonize calls setsid) that asks kill(pid, 0) each second whether the program is still there; refused, it concluded the program had ended and never stopped it. The credentials (step
    1. are the rule now, as on Linux, with SIGCONT allowed within a session whoever owns the target; a group is signalled member by member under that rule, and is EPERM when it has members none of whom may be signalled.
  • A stop holds. Any signal used to wake a stopped task, which then ran its handler and its program with nobody having sent SIGCONT; and a task stopped on a system call's way out returned to its program BLOCKED and ran until the next tick. Only SIGCONT and SIGKILL end a stop now; the system call's exit waits while the task is stopped and delivers what came meanwhile; a child's end no longer wakes a stopped parent. The timer's path cannot wait - it runs on an interrupt stack a second tick would overwrite - so a stop taken there still runs to the end of its tick.
  • Orphaned process groups are hung up (step 6's LTP run). A group left with no member whose parent is outside it in the same session, and with a stopped member, is sent SIGHUP and then SIGCONT, as POSIX requires: LTP's waitpid13 moves half its children into a group of their own and stops them, LTP's kill of the test's group did not reach them, and they held their slots until fork was EAGAIN for the tests after.
  • /proc about threads. /proc/<pid>/stat and status give the leader's state, as Linux does; they gave "running" if any thread ran, so a process whose main thread waited while another watched for it never looked asleep (futex_wait03). /proc/<tid> exists for every thread, unlisted, and task/<tid> is the same directory; a thread's files give its own id, state, name, signals and CPU time, and its process's everything else.
  • What else step 6's LTP run found, fixed: an open /proc/<pid> is a pidfd to pidfd_send_signal, as on Linux; the random devices answer RNDGETENTCNT with what the new /proc/sys/kernel/random/entropy_avail prints - one function for both; clone3 judges where a pidfd is to be written before there is a child (EFAULT, and nothing made).
  • What this step's own LTP run found, fixed: setfsuid(-1) and setfsgid(-1) are how a program asks for the current id, and root's call stored -1 as its id (setfsuid02, setfsgid01); clock_nanosleep answered EINVAL for every clock but three, under a registry row that said done - it sleeps on TAI, the alarm clocks and the process's own CPU time now, and a thread's CPU time and the raw and coarse clocks are EOPNOTSUPP, as on Linux (clock_nanosleep01).
  • Found by CI, not by a test here: the native programs' three-argument syscall wrappers left r10, r8 and r9 holding whatever the compiler had put there, and wait4 has filled its rusage since step 5. Built with clang, init kept its pointer to the service table in r10, every reap wrote a rusage over the services, and init died reading a service name that was the number 1 - CI's clang Release boots were red while every local (gcc) boot was green. Every wrapper passes zero now. And the nightly's corpus job said Linux answered differently because the CI runner starts its steps with SIGPIPE ignored; the expectation is made with every signal at its default.
  • At boot SIGNAL.ELF stops a child waiting in pause() and one running its program, sends each a SIGUSR1 that must wait for the SIGCONT, ends a stopped child with SIGKILL, and leaves a stopped child in a group that its parent's end orphans, which must be hung up (job_control_broken).
  • Sabotage: abi-signal-reach.txt (4), abi-procsrc-threads.txt (2), two cases more in fs-procfs.txt and in abi-sleep-restart.txt, one each in abi-random-ioctl.txt, abi-clone3-checks.txt and sched-cred.txt, and abi-pidfd-calls.txt's and abi-sleep-restart.txt's first cases re-pointed at the code that moved, against the host tests; all red. The clone3 case went red first by crashing the suite - outside its address-space window the fake kernel takes an address for a host pointer - and is caught by its test now. One of fs-procfs.txt's, "a directory lists a name once", had been written down as unverifiable until a second file lived under /proc/sys/kernel; this step put one there. abi-jobs-boot.txt (2) against the boot: both red - the first on job_control_broken only once a running child was asked about too; its first run went red on the transition table instead, and the case file says why. Fifteen boots across the step's three rounds of validation (six, six and three), all clean, and one of the clang Release build.
  • LTP for L2's own calls (tests/corpus/ltp-l2.txt, 188 tests): 106 passed, 6 failed, 26 broken, 50 not applicable, and none left unrun - kill10 no longer takes the rest of its boot. The three it found in the calls themselves are fixed above, and their calls' ten tests run again after it passed ten of ten. What is left: setting the clock (refused, and five tests set it), adjtimex, unshare, select; (2026-10-05, after the close: the registry still named four calls L2's - adjtimex, clock_adjtime, settimeofday, restart_syscall - so "closed" was true of the plan's seven steps and not of the registry. They are served now: the first three read the clock's state or judge their arguments and refuse a setting, EPERM, as clock_settime is refused - partial by decision, phase R - and restart_syscall is Linux's EINTR, since no call here leaves a restart block. abi-clock-discipline.txt, four cases, all red. L2 has no row left.) stopped and continued children reported by wait (waitid07 and 08 wait out their timeout); times() reports no system time; the wall clock reads 1970 at boot, so an absolute time far enough in the past is negative and refused (timer_settime03); a C library's restorer (rt_sigsuspend01, musl's); and resources LTP stages that this run does not (/bin/true, the execveat helpers, /proc/sys/kernel/core_pattern, a loop device). Not looked into: timer_settime02 fails outside the thirty lines a run keeps, and pidfd_open04 waits out an LTP checkpoint. Capacity, not a defect: pause01 forks a child per iteration and reaps them at the end, and the ninth is past the eight children a process may have unreaped on a table of thirty-two slots.

What L2 leaves open, all in the registry or above: stopped and continued children are never reported by wait; capabilities, set-user-id on exec and a set-group-id directory's group; a real-time signal queued twice is delivered once, and a signal to a process goes to its leader rather than to a thread that does not block it; times() has no system time; setting the clock, and a wall clock that starts at 1970 because nothing reads the hardware's; RSS, AS and the other limits that are kept but not enforced; no personality flag changes anything; and a task table of thirty-two slots.

L3. Memory (10, plus mmap finished)

File-backed mappings - private and shared - through the page cache, MAP_FIXED, mremap, madvise, msync, mincore, the mlock family, memfd_create. This is the phase that makes glibc possible: its dynamic loader maps libraries with MAP_FIXED.

A user stack that grows: a fault below it maps a page (step 5, done second).

Programs: the corpus rebuilt against glibc, dynamically linked; sqlite3 with memory-mapped I/O; lua. And LTP, for every phase before this one: its harness maps a file MAP_SHARED for its results and needs that page to stay shared across fork, so not one of its tests can start until this phase. L1's 416 are the first owed (scripts/dev/ltp-run.sh).

Six steps, in the order that pays L1's debt first:

  1. mmap by the rules: the flags read as Linux reads them (one of private and shared, required), MAP_FIXED and MAP_FIXED_NOREPLACE, an address hint honoured when it is free, the arena stepping over what a fixed mapping took, and private file mappings - the file's bytes in the process's own pages.
  2. Shared mappings: MAP_SHARED, anonymous and of a file whose filesystem can hand out its pages (tmpfs), staying shared across fork; msync. This is what LTP's harness needs.
  3. LTP, for L1: its 416 tests run through scripts/dev/ltp-run.sh, and what they find fixed or written down.
  4. The rest of the calls: madvise, mincore, the mlock family, memfd_create, mremap.
  5. A stack that grows, and the 256 KiB mapped at exec given back.
  6. The programs: the corpus against glibc, dynamically linked; sqlite3 with memory-mapped I/O; LTP's own tests for these calls.

Step 1 (2026-10-01): done.

  • mmap places a mapping where the program says. MAP_FIXED maps at the address given and takes the place of whatever was there - how a dynamic loader lays a library's segments over the range it reserved - and MAP_FIXED_NOREPLACE refuses if anything was (EEXIST). An address hint is honoured when the range is free. The arena is still a cursor that moves up, but it steps over what is already mapped: a fixed mapping can be ahead of it now, and it used to hand out the same pages. Where a program may put a mapping is the architecture's to say (ks_user_fixed_ok: the high user window); an address outside it is ENOMEM.
  • The flags are read as Linux reads them: exactly one of private and shared, or EINVAL; MAP_ANONYMOUS ignores the descriptor (it used to refuse one that was not -1).
  • A private mapping of a file is the file's bytes in pages of the process's own, read when the mapping is made, from any filesystem - the page is mapped writable, filled through the file's own read, and given the protection asked for. A store stays in the process; the mapping outlives the descriptor. Past the end of the file it reads zeros, where Linux raises SIGBUS beyond the file's last page. EACCES for a descriptor that cannot be read, ENODEV for what is not a file.
  • Still refused, and said: a shared mapping of a file (ENOSYS); a shared anonymous one is made and is private after a fork. Both are step 2, and the registry's mmap line names them.
  • The fake kernel has page tables. The memory handlers could not run in the host tests - the fake failed every mapping and said why. It now runs kernel/mm's frame layer and address-space layer over a few megabytes, as their own tests set them up; a mapped address is read and written through the tables (kf_peek, kf_poke), a kernel-mode store takes the copy-on-write fault, and fork clones the space. So mmap, munmap, mprotect and brk are host-tested as handlers for the first time, fork's copy-on-write included.
  • At boot the ring-3 self-test maps over a page at a fixed address and reads zeros where it had written, is refused by NOREPLACE, and maps its own image off the FAT volume privately: the ELF magic, and a store that sticks.
  • Sabotage: abi-mmap.txt (18) on the host, abi-mmap-boot.txt (1). Three went NOT RED first; two were the test's - a pattern that repeated every 256 bytes, so the byte at offset 4096 was the byte at 0 - and one was the code's: the handler's unmap before a fixed mapping is not what releases the old pages (the address-space layer does that), it is what lets the region list describe the new mapping.
  • A race in fork, found while reading for M-070, and fixed. For a page that needs no conversion - read-only, or copy-on-write already - fork reads the entry and, some instructions later, maps the frame into the child. A page-out marks only writable pages, so it can evict that page and release the frame in between, with nothing in the entry either side would notice: the child is mapped onto a free frame, which the allocator hands to somebody else. clone_one now pins the frame only if it still has an owner and reads the entry again before mapping. A host test puts the page-out exactly there through the fork hook, with the frame left free and with it reused; before the fix it found the child on a frame the page-out had given away. fork_frame_gone and fork_entry_moved count the two cases on every boot.
  • M-070 itself is not fixed, and is more frequent than it was. The swap map's double release - not seen in ninety boots before L1 step 8 - has been seen three times in about sixty since that step gave every process sixty cold stack pages, the third time with the fix above in place and its counters at zero: so that race, real as it is, is not what these boots hit. What the three captures agree on: the reclaim load is running; a process is killed reading a page whose entry is a valid swapped one (read-only, user); its slot is found already free when the address space is torn down, last given back by a page-in. What they do not say is whose page-in, or why the one that killed the process failed. The kernel says both now - SWAP_IN_FAILED with the reason and the slot's history, and each slot's last writer and last releaser by process - and sixteen boots after adding that were clean, so the next capture is still owed. The boot gate is therefore flaky, at something like one boot in twenty, and it was this phase's predecessor that made it so. Step 5 (a stack that grows, no pages mapped that nobody touched) removes what raised the rate; it does not explain the defect.

Step 2 (2026-10-01): done.

  • A shared mapping stays shared. MAP_SHARED marks the page (VIBEOS_PROT_SHARED, bit 53 of the entry), and a fork maps the same frame into the child with the access the parent has, instead of making both sides copy-on-write. A store by either is seen by both, before the fork or after. mprotect changes the access and leaves the kind; a read-only shared page refuses a store rather than copying.
  • A shared mapping of a file is the file. The filesystem hands out its own page (share_page, a new optional operation; tmpfs has it) and that page is what is mapped: a store through the mapping is read back by read, a write is seen through the mapping, two mappings of one file are one page. A hole is given a page. The page comes held - the reference is taken inside the filesystem's lock, where it can still vouch for it - and each mapping holds it too, so a file truncated or unlinked under a mapping leaves the mapping its page, and the page is freed when the last holder lets go.
  • Shared and writable needs a descriptor that can write (EACCES); shared and read-only does not, and neither does private and writable.
  • msync validates and returns: there is no second copy to write back. EINVAL for an address that is not a page's or flags that make no sense, ENOMEM for a range with a hole in it.
  • A shared page is not paged out. An entry in swap has nowhere to say "shared", and a page that came back without the mark would be private from then on (swap_refused_shared).
  • What is not done, and said: a shared mapping of a file on a filesystem that keeps no pages - FAT - is ENODEV, and is the registry's mmap gap. Only the pages holding some of the file are mapped; one past the end is absent, and touching it is SIGSEGV where Linux says SIGBUS. A mapping does not follow its file: grown after it was mapped, the new pages are not in the mapping; cut and grown again, the mapping still has the old page. mprotect will make writable a shared mapping made from a read-only descriptor.
  • The layering check gained one exception, written into it: the hold on a file's page is a frame reference taken outside kernel/mm, by hw_tmpfs_page_hold and nothing else.
  • At boot the self-test maps two anonymous pages and a page of a file on /tmp shared, forks, and the child stores into each: the parent has to see the stores and pread has to return the file's (shared_mapping_selftest_failed); and the fork has to have handed shared pages on (fork_never_kept_a_shared_page).
  • Sabotage: abi-mmap-shared.txt (5), mm-shared.txt (4), fs-tmpfs-share.txt (3) against the host tests, arch-shared-boot.txt and arch-shared-leaf-boot.txt against the boot; all red. 24 boots of 24 clean.
  • LTP: its harness maps its results page and gets further - every test now stops one step later, at tst_memutils.c:94: Failed to open FILE '/proc/meminfo' (eight tests tried). That is step 3's first item, not a mapping's.

Step 3 (2026-10-02): done - the oracle runs, and what it said.

scripts/dev/ltp-run.sh build-gcc-Release --list tests/corpus/ltp-l1.txt runs the 382 LTP tests for the syscalls L1 owned (scripts/dev/ltp-list.py; the "416" above was counted against an earlier registry), thirty-two to a boot, and leaves each verdict in <build>/ltp-results.txt. The first run, before anything below was fixed: 113 passed. The last: 131 passed, 10 failed, 177 broken, 57 not applicable, 7 did not run.

What had to exist before a test could start. None of it is a mapping's:

  • /proc, a filesystem (kernel/fs/procfs.c) with what the harness reads: /proc/meminfo and /proc/sys/kernel/pid_max. Generated on read, no state.
  • /etc/passwd and /etc/group on the boot volume: a C library answers getpwnam("nobody") out of them.
  • nanosleep and clock_nanosleep (L2's, taken early). They did not exist: every sleep and usleep in every program had been returning at once, and nothing checks what they return.
  • A call a signal cut short is run again when the handler was installed with SA_RESTART, or when no handler ran. Every interrupted wait had been EINTR, so a parent whose child signals it - LTP's harness - saw waitpid fail. A handler returns VIBEOS_RESTART_CALL where a wait can simply be started again (waiting for a child, a terminal, a pipe, a lock); the dispatcher turns it into EINTR and remembers the call; the delivery decides.
  • More than thirty-two programs in a boot (M-081, below).

What the tests found in L1's own calls, fixed:

  • F_GETLK named whichever conflicting lock the table held first; it names the one that starts first (fcntl11, fcntl21).
  • A path's first byte was read without asking whose memory it was (M-082, statx03): ring 0 reads a page that is mapped for nobody, so statx of a path in a PROT_NONE page, or in the kernel, answered ENOENT or EFAULT by the byte it found there.
  • An empty path was looked up from the directory descriptor: ENOTDIR or EBADF where Linux says ENOENT (fchmodat02).
  • preadv/pwritev with a negative length were EFAULT, not EINVAL; reading a directory at an offset was ESPIPE, not EISDIR (preadv02, pwritev02).
  • fallocate with FALLOC_FL_KEEP_SIZE did not look at how far it reached: past the largest offset is EFBIG (fallocate02).
  • A process that opened files until refused was refused by the machine (ENFILE at 256) and not by its own limit (EMFILE at 1024): the description pool is 2048 (creat05, fcntl12).
  • socket() of a family there is none of was EINVAL, which a C library takes for a failure where EAFNOSUPPORT means "no name-service daemon" - so getgrgid of an unknown group failed (seven tests).

What they found that is written down and not fixed:

  • Credentials are L2's: setuid, seteuid, setgid, setegid, setreuid and getpgid are missing or refuse, so every test that becomes "nobody" stops in its setup, and the ones that check a permission is refused cannot (symlink03). The largest group after the next.

  • No block device to borrow: about ninety tests ask LTP for a loop device (Failed to acquire device) to make a filesystem on. Not a syscall's fault.

  • alarm and setitimer are L2's, and LTP's own timeout is alarm(): a test that hangs is not stopped, and takes the rest of its boot (flock03 and the tests after it in that boot did not run).

  • /dev/null, /dev/urandom, a pty, /proc/mounts, /proc/self/maps, /proc/cpuinfo, /proc/version (L2); socketpair (L5); pthread_create refused under OFD-lock tests (L6).

  • Leases (F_SETLEASE), O_PATH descriptors answering EBADF to I/O, FIFOs from mknod/mkfifo, RENAME_EXCHANGE: gaps in L1's own rows, the last two already in the registry.

  • xattr tests report TCONF, correctly: no filesystem here keeps them.

  • Sabotage: abi-ltp-l1.txt (6), abi-restart.txt (4), abi-sleep-restart.txt (4), fs-procfs.txt (3), one more in fs-filelock.txt; all red. Two went NOT RED first, and the reason was the fake kernel: its vibeos_uaccess_copy refused a page not mapped for the user, which the machine's does not - so the test for M-082 could not fail there. The fake's kernel copy now reaches what ring 0 reaches, and the self-test checks the same thing on the machine. check.sh all green, the sanitized host tests clean, 20 boots of 20 over two runs.

Step 4 (2026-10-02): done.

  • madvise. Advice about speed is taken by doing nothing: every page is already there. MADV_DONTNEED and MADV_FREE are not advice - a program reads zeros afterwards and an allocator relies on it - so a private anonymous page is given back and a zeroed one put in its place. A shared page is left as it is; a locked one refuses. Advice that would change what a later call does (MADV_DONTFORK, the huge-page pair, MADV_REMOVE) is EINVAL rather than a promise not kept. Gap, in the registry: MADV_DONTNEED on a private page of a file keeps what the program stored, where Linux shows the file again.
  • mincore: a byte a page - in memory, or not (in swap, or a part of a mapping nothing backs).
  • mlock, mlock2, munlock, mlockall, munlockall. A locked page carries a mark in its entry (VIBEOS_PTE_LOCKED, bit 54) and page-out refuses it; a page in swap is brought back when it is locked. The copy a write fault makes, a change of protection and a move keep the mark; a fork does not hand it to the child. MCL_FUTURE is kept: mmap, brk, mremap and the stack as it grows lock their new pages as they are made, and a mapping RLIMIT_MEMLOCK cannot hold is refused (until 2026-10-18 it was accepted and not kept - M-104).
  • mremap. Shrinks; grows where it stands when the address space after it is free; moves when it is not and MREMAP_MAYMOVE allows, or to the address MREMAP_FIXED names. A move takes each entry out of the old address and puts it at the new one - the same frame, the same marks, the same reference; nothing is copied (vibeos_vmspace_move). A failure half-way puts back what was moved. Gap: only a private anonymous mapping grows - a shared one or a file's would need more of what it shares, and the region does not remember where that came from; a zero old length and MREMAP_DONTUNMAP are refused.
  • memfd_create. A file on /tmp that goes when its last descriptor does: memory with a file's interface, to size, map shared and pass on. Gap: it has a name under /tmp while it is open, where Linux's has none anywhere; and there are no seals.
  • A region says what it is - anonymous, a file's bytes in private pages, or shared - because three of these calls have to ask.
  • At boot the self-test discards a page and reads zeros, locks two pages and asks mincore, grows a mapping that has to move and finds its contents, and shares a memfd across a fork (memory_calls_selftest_failed).
  • Sabotage: abi-mem4.txt (14), mm-lock-move.txt (4), abi-memfd.txt (3) against the host tests, abi-mem4-boot.txt against the boot; all red. check.sh all green, the sanitized host tests clean, 21 boots of 21 (run three at a time).
  • LTP, the tests for these calls and for mmap and msync: 32 tried, 20 pass. mmap08 found one more wrong answer, fixed (a mapping of a descriptor that is not open is EBADF before a zero length is EINVAL). Of the rest: mincore03 expects an untouched anonymous page not to be in memory, and here a mapping is populated when it is made; mremap01 grows a shared mapping of a file (the gap above); the others want setrlimit, /proc/self/maps, shmget, seals or mount.

Step 6 (2026-10-02): done. L3 is closed, with its gaps named.

  • glibc, dynamically linked, runs. SQLite's shell and Lua are built a second time with the host's compiler against the host's glibc (scripts/dev/corpus-build.sh: sqlite3-glibc, lua-glibc), and the loader and the libraries they name - ld-linux-x86-64.so.2, libc.so.6, libm.so.6 - are copied to the boot volume at the paths the programs ask for them by. glibc's loader reserves a range and lays each library's segments over it with MAP_FIXED, private mappings of the file: step 1. Nothing had to be added for it; the first program ran on the first try.
  • SQLite with memory-mapped I/O, in write-ahead-log mode. The WAL's index is a file every connection maps shared - the journal mode cannot be entered without step 2 - and with mmap_size set the database is read through a mapping. Two connections, four hundred rows, an update, a checkpoint and an integrity check; the -wal and -shm files are gone afterwards, as on Linux.
  • Three workloads more in the corpus (glibc-sqlite, glibc-lua, sqlite-wal-mmap), eighteen in all, compared line for line with what Linux printed. They matched on the first boot.
  • Sabotage: abi-corpus-l3-boot.txt, two cases, red - a MAP_FIXED that is not honoured takes the glibc workloads down, a shared file mapping made private takes the WAL one.
  • LTP for L3's own syscalls (tests/corpus/ltp-l3.txt, 66 tests): 31 of 63 pass, with two left out because they hang (mincore04, mmap18). It found three more wrong answers, fixed: MAP_SHARED_VALIDATE did not refuse an unknown flag, MAP_LOCKED was ignored, and msync(MS_INVALIDATE) of a locked page was not EBUSY. Not fixed and said: a fault in ring 3 kills the task instead of delivering SIGSEGV or SIGBUS to a handler that asked for it (mmap05, mmap13, and why mmap18 hangs); mincore03 expects an untouched page not to be resident; mremap01 aborts after mremap refuses to grow a shared mapping of a file - most likely in its own cleanup, which was not confirmed. The rest want setrlimit, /proc/self/maps and status, shmget, seals, mount or a loop device.
  • check.sh all green, the sanitized host tests clean, 12 boots of 12 three at a time and 5 of 5 through the full gate. A later run of six in parallel ran out of its 300 seconds with every boot still working through the corpus: the host was at 70% load from something else and everything on it ran at half speed.

What L3 leaves open, all in the registry or above: a region does not remember its file, which is one cause with three faces (a shared or file mapping does not grow under mremap, MADV_DONTNEED keeps a private page of a file, a page past the end of a mapped file is absent rather than SIGBUS); a shared mapping of a file needs a filesystem that keeps pages, so not FAT; a mapping is populated when it is made, so mincore never says "not yet"; MCL_FUTURE; seals on a memfd.

Step 5 (2026-10-01), done ahead of its turn. Taken before step 2 because of what step 1 found: the boot gate had become flaky, and the pages mapped at exec that nobody touched were what had made it so.

  • The stack grows. Four pages are mapped at exec, as before L1 step 8, and the two megabytes below the stack's top are one region the process has from the start. A touch of a page in it that nothing maps is given a zeroed page by the page fault (hw_stack_grow), whichever side the touch comes from - ring 3, or the kernel storing a syscall's result into a buffer on the stack. Any address in the region grows it, not only one near the stack pointer: that is what Linux has done since it dropped the stack-pointer test.
  • The region list is asked, not only the address. A program that unmapped part of its stack, or took its write permission away, is not given a page there. The region's lowest page is never mapped and below it is nothing of the stack's, so running off the end is a fault.
  • The range check knows. A syscall's buffer is judged before the handler runs, and a page of the stack nobody has touched is the process's to use: refused, a read() into a large buffer on the stack - where programs keep them - would be EFAULT for every page not happened upon first.
  • Under the process's mm lock, which is tried and not waited for: this is a fault handler, a thread of the same process may be forking on another core, and returning to fault again is how it waits. The frame comes from the privileged door, as a page coming back from swap does: the stack was promised at exec, and it is bounded.
  • RLIMIT_STACK reports the two megabytes; it reported the four pages, then the sixty-four.
  • At boot the ring-3 self-test runs a function with a hundred-kilobyte frame, has the kernel read() into a page a quarter of a megabyte below it, and forks a child that stores into the guard page and must die of SIGSEGV (stack_selftest_failed). The kernel counts the pages it gave (stack_grown), and a boot in which it gave none fails (stack_never_grew): the corpus prints through a sed that needs them.
  • The gate's count of deliberate faults is two now, and each is matched by the address it touched - null for svc-crash, the guard page for this - where it used to excuse whichever ring-3 page fault came first. The crash-record check reads every record now and wants svc-crash's among them: it read the first one only, and the first 24-boot run failed four times on a record that correctly named SELFTEST.ELF, whenever that child died before svc-crash.
  • Sabotage: arch-stack-boot.txt, two cases, red. Three went NOT RED first. The compiler probes every page of a large frame on entry, so the read() into the middle of the big array wrote into a page ring 3 had already touched - the test reads below the frame now. Widening the address test to include the guard page changed nothing, because the region list refuses it too. And a loop that zeroed the new page was dead code: the allocator hands out nothing but zeroed pages.
  • What it did to M-070: 48 boots in two runs, none with its signature (the four that failed were the gate's own crash-record check, above), against three in about sixty before with the stack growing. A boot gives out about sixty stack pages in all, where it mapped sixty per process. The defect is not explained and its instrumentation stays; what changed is how much reclaim has to take that nobody is using.

L4. Event loops (21)

poll, select, pselect6, ppoll, epoll, eventfd, timerfd, signalfd, inotify - each a file type from A3 with a wait queue, not a special case in the scheduler.

Programs: BusyBox httpd serving from the guest, nc, tail -f.

The plan (2026-10-04). Measured first, as A0 measured the corpus: the three programs under strace on Linux, with the BusyBox the image stages. httpd asks for no event loop at all - it forks a child per connection, bounds it with alarm, and sends with sendfile, setsockopt and shutdown; nc, server and client, waits in poll on its socket and on standard input; tail -f sleeps and looks again (clock_nanosleep, fstat). So what the programs exercise is poll on a socket - which is what poll cannot answer today, a socket always saying it is ready - and two of L5's calls, which are taken here as far as httpd needs them, as L3 took nanosleep. The rest of the phase has LTP for its oracle, and its own boot checks: tests/corpus/ltp-l4.txt, 78 tests. scripts/dev/ltp-list.py found only 50 of them at first - LTP names some directories after a family (inotify01, pselect01, timerfd01, epoll-ltp) and those matched no syscall, and timerfd01 matched time. It matches whole names now, then families; the same change found twelve of L1's (pread01, pwrite01 and the rest) that L1's list never had, and they run with step 1.

No wait queue. Every wait in this kernel is the same: look, give up the core, look again - pipes, sockets, futexes, poll itself - and what a description can do is its type's ready. The event loops are built on that, not beside it: an interest list asks each description's ready at each look. A wait queue would be a second model of waiting for one phase. Edge-triggered epoll is a rise in what ready says between two looks.

Seven steps, in the order that pays what is owed first (select is missing, and L2's personality02 stopped on it):

  1. One readiness engine, four calls: poll finished - a socket says what it can do, from the stack (data to read, a connection to accept, a peer that closed, room to send) - and ppoll, select, pselect6 on the same engine: a set of descriptors and events, looked at until one is ready, the time is up or a signal needs acting on. ppoll and pselect6 swap the signal mask for the wait, atomically, as rt_sigsuspend does; select writes back what was left of its time, as Linux does.
  2. eventfd, eventfd2: a counter as a file type - EFD_SEMAPHORE, EFD_NONBLOCK, EFD_CLOEXEC; a read waits at zero, a write at overflow.
  3. timerfd_create, timerfd_settime, timerfd_gettime: a timer as a file type, read for how many times it expired; relative or absolute, with an interval.
  4. signalfd, signalfd4: a thread's pending signals read as records - the same take sigtimedwait uses - and ready while one of its mask is pending.
  5. epoll: epoll_create, epoll_create1, epoll_ctl, epoll_wait, epoll_pwait, epoll_pwait2. An interest list as a file type, each entry a description and its events; level- and edge-triggered, EPOLLONESHOT; an epoll in another epoll's or in poll's set; an entry goes with the description, not the descriptor, as on Linux.
  6. inotify: inotify_init, inotify_init1, inotify_add_watch, inotify_rm_watch. Events from the file layer's operations - made, removed, written, changed, moved, opened, closed - at the one place every filesystem's operation passes, queued per instance, with an overflow.
  7. The programs: BusyBox httpd serving from the guest to the host, nc both ways, tail -f; and LTP for L4's own calls.

Step 1 (2026-10-05): done.

  • One engine, four calls (kernel/abi/linux/poll.c): a set of descriptors and what is asked of each, looked at until one can, the time is up, or a signal needs acting on - and what was ready wins over a signal that came meanwhile. poll moved here from fs.c; ppoll, select and pselect6 are new. The hangup and the error are reported whether asked for or not; select counts them as Linux does (a hangup is readable, an error both). A wait is never shorter than asked - the tick in progress is not counted, as a sleep's is not, which poll used to get wrong by up to a tick
    • and ppoll, select and pselect6 write back what was left of their time, going on without it when the memory cannot be written, as Linux does.
  • A mask for the wait. ppoll and pselect6 swap the signal mask for the wait as rt_sigsuspend does: a signal it lets through is delivered under it and the program resumes under its own; a call that does not end in EINTR has the program's mask back before it returns.
  • Sockets say what they can do (vibeos_inet_ready in kernel/net/inet.c, socket.c's ready), as Linux's tcp_poll says it: a listener only with a connection to accept, a connection with data or after the peer's FIN (also POLLRDHUP), a socket nobody connected writable and hung up, a reset one everything. A socket had no ready, which reads as always ready for both, so nc - which waits in poll on its socket - never waited.
  • Two new readiness bits in the file layer, an error and the peer's half closed (VIBEOS_READY_ERR, _RDHUP). A pipe whose reader has gone is an error to its writer, where poll had a special case asking which end of a pipe it was looking at; an empty pipe whose writers have gone is a hangup and only that (LTP's poll03 - it was readable too).
  • What the step's LTP run found in L1: twelve of L1's tests its list never had (ltp-list.py, above) ran for the first time, and one failed - under O_APPEND, Linux's pwrite writes at the end and ignores its offset, a documented bug kept for compatibility (pwrite04). It does here now.
  • At boot EVENTS.ELF (tests/linux/musl_events.c), the phase's own program, polls three sockets that nothing has happened to, waits in select on an empty pipe and measures that the wait was not short, sees a byte make it readable, and waits in ppoll and pselect under masks that let a pending signal through and keep it out (events_l4_failed). It ran on Linux first: the checks describe Linux.
  • Sabotage: net-inet-ready.txt (4), abi-socket-ready.txt, abi-event-loops.txt (11, six of them poll's from abi-tty-fs.txt, moved with the code), a case more in abi-tty-pipe.txt and in abi-write-path.txt, against the host tests; abi-events-boot.txt and abi-poll-boot.txt (moved from abi-tty-boot.txt) against the boot; all red. "select's sets are not judged" went NOT RED, correctly - a set is written back where it was read, and a page the program may not read is one it may not write, so the call is EFAULT either way; what the judgement keeps out is a page ring 0 may read and write and the program may not, which the fake kernel does not have. It is written down in the case file instead of kept. Twelve boots across two rounds (six and six), all clean.
  • LTP: the step's own fifteen and L1's twelve: 23 of 27 passed. Of the rest, poll03 and pwrite04 (twice) are fixed above and the six tests of the calls they touch, run again after, passed six of six; select01 makes a FIFO with mkfifo in its temporary directory and is refused - L1's gap on mknod, not the event loop's. select02 to 04 pass their own variant and say so of the time64 one, which x86-64 does not have.

Steps 2 to 4 (2026-10-05): done, together. Three descriptors that are events, each a file type with a ready and a pipe's way of waiting, so poll, select and (step 5) epoll see them without being told.

  • eventfd, eventfd2 (kernel/abi/files/eventfd.c, personality-neutral): a counter in the description, so dup and fork share it; every change a compare-exchange, because two processes after a fork can read and write it at the same instant and a description has no lock. A read takes the count, or one under EFD_SEMAPHORE, and waits at zero; a write adds and waits for room under the largest count, 2^64 - 2; the value past it is EINVAL. A read whose buffer cannot take the value gives the count back.
  • timerfd_create, timerfd_settime, timerfd_gettime (kernel/abi/files/timerfd.c): a deadline and a period in ticks, and nothing that fires them - whoever looks, a read or a poll, counts the expiries since the last look. Every clock a timerfd may use counts time passing, which is one clock here, so "how many periods have gone by" is arithmetic, and a timer nobody reads costs nothing and needs no slot in the process-timer table. Relative and absolute, with an interval; a relative time does not count the tick in progress, as a sleep does not; set again, it starts counting again. The alarm clocks are the superuser's, a CPU-time clock EINVAL, as on Linux. TFD_TIMER_CANCEL_ON_SET is accepted and never cancels: no clock here can be set.
  • signalfd, signalfd4 (kernel/abi/linux/events.c - its records are Linux's layout, linux_signalfd_siginfo_t): a read takes, of the signals in its mask, those pending for the reading thread, lowest first, as many records as fit - by ks_signal_take, the take sigtimedwait uses, so a signal read is not delivered too - and waits while none is. Readable while one of its mask is pending for whoever asks. A second call on it changes its mask.
  • At boot EVENTS.ELF waits on each in a way only another process or the clock can end: an eventfd read woken by a child's write, poll on a periodic timerfd (not early, and every period counted), a signalfd read and a poll on one woken by a child's kill. It ran on Linux first.
  • Sabotage: abi-eventfd.txt (5), abi-timerfd.txt (4) and abi-signalfd.txt (6) against the host tests, and abi-signalfd-boot.txt against the boot (events_l4_failed, for the reason it names); all red. No case removes a wake: every wait here looks again at the next tick, so a missing ks_wake_waiters costs a tick and nothing a test can see.
  • Sabotage, one more: abi-signalfd.txt's seventh case checks the pointer before the descriptor, and is red.
  • LTP: the steps' twenty tests: 16 passed. timerfd_gettime01 failed: with a closed descriptor and a bad pointer Linux says EBADF, and the row judged the pointer first - a timerfd setting is written out last now, by the handler, as Linux orders it, and the test passes. timerfd_settime02 wanted /proc/sys/kernel/tainted (0 now: nothing here taints the kernel) and then races two threads with LTP's fuzzy sync, which asks sched_getaffinity - L6's - and outlasts a boot under TCG. timerfd04 needs a time namespace (unshare(CLONE_NEWTIME)), eventfd06 libaio: neither is this phase's. Six boots, all clean.

Step 5 (2026-10-05): done. epoll_create, epoll_create1, epoll_ctl, epoll_wait, epoll_pwait, epoll_pwait2 (kernel/abi/linux/epoll.c).

  • poll with a set that outlives the call. An entry is a description, the number it was added under, the events asked and the program's word; a look asks each description's ready, as poll does, and the wait is poll's engine (linux_wait_ready, now shared). Nothing wakes an epoll and no file type calls into it.
  • An entry is the description's. It holds no reference: closing one of two descriptors leaves it, and the last reference going takes it away - the file layer calls a hook (vibeos_file_on_release) before the slot can be handed out again, so an entry never watches a slot's next tenant. A look takes a reference to each description by vibeos_file_try_get, which refuses one whose release has begun.
  • Edge-triggered is a rise: what ready says now that it did not at the last look. Linux's is "something happened", which also reports a pipe that was readable and got more; with no event to report here, only a state, a rise is the most a state can say. A program that reads until EAGAIN before it waits again - which is what edge-triggered requires of it on Linux too - sees no difference.
  • Nested: an epoll is readable while one of its entries would be reported, so it can be in another's set or in poll's; a cycle, or nesting deeper than Linux's five, is ELOOP. A look never asks a ready under epoll's lock, so an epoll inside an epoll cannot deadlock on it.
  • EPOLLONESHOT disarms an entry once reported, until EPOLL_CTL_MOD; EPOLLEXCLUSIVE is accepted with Linux's rules for it and means nothing more: nothing is woken, so there is no herd to keep from waking. The entries are one pool of 512 for every instance.
  • At boot EVENTS.ELF waits in epoll_wait for a child's write to a pipe, edge-triggered, then level-triggered, and polls the epoll.
  • Sabotage: abi-epoll.txt (10) against the host tests and abi-epoll-boot.txt against the boot. "An entry outlives its description" went NOT RED first, and correctly: try_get skips a released description, so a left-behind entry is invisible until its slot is handed out again. The test makes a readable description in that slot now, and it is red.
  • LTP: the step's 23 and timerfd01 and timerfd_settime01 again: 16 passed. epoll_ctl04 failed: it builds a chain of five epolls and asks for a sixth on top, and the check counted only the chain below the epoll being added. It counts the whole chain once joined now - what is below and what is above - and the five epoll_ctl tests, run again, passed five of five; a sabotage case more (11) keeps it. Five epoll_pwait tests need socketpair and epoll_pwait06 Unix sockets (L5); epoll_wait05 listen on an unbound socket, epoll_wait06 F_SETPIPE_SZ: both open, neither epoll's. Six boots, all clean.

Step 6 (2026-10-05): done. inotify_init, inotify_init1, inotify_add_watch, inotify_rm_watch (kernel/abi/linux/inotify.c).

  • The file layer says what happened, once (vibeos_fs_notify, kernel/fs/vfs.c): every operation on a name - create, mkdir, symlink, link, unlink, rmdir, rename, setattr - tells one registered listener what it did, to which node, at which path, and a rename's two halves share a cookie; the regular-file type tells open, read, write, truncate and close (regular.c). The numbering is the file layer's (VIBEOS_FSN_*), equal to Linux's where both have the event, and a personality maps it. Nothing is looked up for anybody while no instance exists.
  • A watch names a node - a mount and the filesystem's identity - so on tmpfs it follows a rename, and on FAT, whose identity is where an entry sits, it does not: FAT's gap, not inotify's. The node's own watch hears its events unnamed, its directory's hears them named; a link count is the node's alone, and a directory's DELETE_SELF and MOVE_SELF carry no IN_ISDIR, as Linux reports them. A queue of 31 events and then one IN_Q_OVERFLOW, merged when identical and consecutive; IN_ONESHOT, IN_MASK_ADD, IN_MASK_CREATE, IN_ONLYDIR, IN_DONT_FOLLOW; FIONREAD; and /proc/sys/fs/inotify/max_* say the limits, which here are the machine's (8 instances, 128 watches).
  • At boot EVENTS.ELF watches a directory in /tmp, polls the instance until a child makes a file there, reads the record, and renames it for the MOVED_FROM/MOVED_TO pair with one cookie.
  • Sabotage: abi-inotify.txt (10), fs-notify.txt (4), abi-files-notify.txt (3) and abi-paths-moved.txt against the host tests, abi-inotify-boot.txt against the boot; all red. "The node hears its directory's events" went NOT RED first: the file's watch asked for nothing the directory hears. It asks for the MOVED_* events too now, and is red.
  • What the step's LTP run found, of which one defect is not inotify's at all. inotify02 renames its own working directory and goes on with relative names: a process stands in a path here, so every name after the rename was ENOENT, and LTP's own cleanup failed the same way. A directory renamed now takes the working directories, roots and open descriptions inside it along (linux_paths_moved, vibeos_file_paths_moved). inotify04 asked for the link count's IN_ATTRIB without the directory hearing it, and a directory's DELETE_SELF bare; inotify05 and 06 for the /proc limits. All four are fixed with host tests and run again in step 7. inotify03, 07 and 08 need a loop device; inotify09 is a fuzzy-sync race (LTP's harness asks sched_getaffinity, L6) and outlasts a boot, so it is in tests/corpus/ltp-hangs.txt beside timerfd_settime02. Six boots, all clean.

Step 7 (2026-10-05): done - the programs. tests/corpus/run-l4.sh, staged as /corpus/progs.sh and run by the boot's shell before the corpus:

  • tail -f follows a file another command appends to - it sleeps and looks again, which needed nothing new.
  • BusyBox httpd serves a page from /tmp/www, and the boot gate fetches it from the host through a port QEMU forwards (hostfwd, its own pair of ports per parallel run, checked free before QEMU starts); the gate asserts on the bytes that came back.
  • nc both ways: to the host's echo server, and listening for a line the gate makes up and sends.

What they needed, measured with strace on Linux before any of it was written: two of L5's calls, taken as far as these programs go. setsockopt accepts SO_REUSEADDR (what binding here does anyway) and SO_KEEPALIVE (no probe is sent); anything else is ENOPROTOOPT, partial in the registry with L5 named. shutdown half-closes: SHUT_WR sends the FIN and the socket still reads, a send after it is EPIPE with SIGPIPE, SHUT_RD ends reads once the queue is empty, and a close after a SHUT_WR lets the exchange finish instead of freeing the slot under the peer's last FIN.

Three defects the programs found, none in a call the step wrote:

  • The stack's flush could loop for ever with the network lock held. It counted what was left to send as snd_una + tx_len - snd_nxt, unsigned; a FIN takes a sequence number and no byte, so once the peer had acknowledged the data and not yet the FIN the count was minus one - four billion - and the core sent the buffer's neighbours until the machine was stopped, every other core queued on the lock. A close with data in flight could always have done it; nc, which writes and shuts down at once, did it every time. The FIN is now queued behind the data and sent by the flush, which never counts below zero. Found by the wedge report naming tcp_flush on one core and vibeos_sockfile_stable waiting on another.

  • A wait on a socket heard no signal. accept, the reads and connect looked again every tick and never asked whether a signal needed acting on, so an httpd parked in accept could not be killed, and the machine - which leaves userland only when every user task has ended - never did. They stop for a signal now (accept and the reads restart under SA_RESTART, connect is EINTR), and a socket that may not block does not wait.

  • The host held a port. A forward to a port somebody holds makes QEMU exit at once, which read as "QEMU exited before serial connection"; the gate says INFRA: and the port now, probing it as QEMU binds it (with SO_REUSEADDR), so that a port in TIME_WAIT is not mistaken for a port in use.

  • Sabotage: net-inet-shutdown.txt (4; the first is run under timeout, because the defect it restores is a hang), abi-socket-wait.txt (2), and the host tests for the handlers; at boot, l4-programs-boot.txt breaks each program's half of an exchange in the script (5, one per reason the gate can give: tail_f_did_not_follow, httpd_not_served, nc_out_no_answer, nc_in_not_received, l4_programs_did_not_finish). Two went NOT RED, correctly, and are written down in their case files rather than kept: "the FIN goes ahead of unsent data" - this stack sends every byte at once and leaves data unsent only while ARP is pending, which an established connection never is - and refusing SO_REUSEADDR in the kernel, which BusyBox sets through a helper that ignores the answer. The first run of the boot cases also found the gate's own port probe wrong: it bound without SO_REUSEADDR, so a forwarded port the previous boot had just closed - in TIME_WAIT on QEMU's side - read as taken, and three of the five cases went red for INFRA: instead of their reason. The probe binds as QEMU does now, and those three were run again.

L4 is closed (2026-10-06). LTP for the whole phase - tests/corpus/ltp-l4.txt and the three clock calls L2 handed over, 85 tests: 83 ran, two left out on purpose (timerfd_settime02, inotify09, both fuzzy-sync races that need L6's sched_getaffinity). 58 passed. Two were this phase's and are fixed: inotify10 reads a directory's record and its child's in one instance and wanted the parent's first, and adjtimex03 wanted adjtime()'s bit (0x8000) without ADJ_OFFSET refused as EINVAL before the privilege is asked; both were run again after, with the inotify tests they touch. The rest, by owner:

  • L5: socketpair and AF_UNIX sockets (epoll_pwait01 to 06); listen on a socket nobody bound, which Linux binds to a port of its own (epoll_wait05).
  • By decision: setting the clock (adjtimex01, clock_adjtime01 and 02, settimeofday01 - refused, phase R, as clock_settime).
  • Elsewhere: capget (settimeofday02, L9); a time namespace (timerfd04); F_SETPIPE_SZ (epoll_wait06); mkfifo in a temporary directory (select01, L1's mknod gap); libaio (eventfd06) and a loop device (inotify03, 07, 08) that the run does not stage.
  • Open in inotify itself: inotify06 writes the limits in /proc/sys/fs/inotify, which are read-only here; inotify12 reads an instance's watches from /proc/self/fdinfo, which does not list them; and inotify11, which opens files as fast as another process deletes them, was killed at LTP's timeout - not looked into.

L5. Sockets (11, plus readv/writev on sockets)

The rest of the BSD API (sendmsg/recvmsg, shutdown, getsockname, getpeername, socketpair, socket options, accept4, sendmmsg/recvmmsg) and AF_UNIX stream and datagram sockets, with SCM_RIGHTS on A3's open file descriptions.

Programs: BusyBox wget against the host, a DNS lookup through the C library, a client and server over a Unix socket.

The plan (2026-10-06). Measured first, with strace on Linux and the BusyBox the image stages. wget needs little: socket, connect, read and write, and fcntl turning O_NONBLOCK on and off on the socket. A DNS lookup through musl's getaddrinfo needs more: /etc/hosts and /etc/resolv.conf, a UDP socket made with SOCK_NONBLOCK|SOCK_CLOEXEC in its type, bind to port 0, sendto with MSG_NOSIGNAL, poll, and recvmsg for the answer and the address it came from. L4 left four things here: socketpair and AF_UNIX (epoll_pwait01 to 06), listen on a socket nobody bound (Linux binds it to a port of its own; epoll_wait05), the rest of setsockopt, and a recv that gives up with EIO after ten seconds where Linux waits. LTP is the oracle for the calls: tests/corpus/ltp-l5.txt, the phase's own 36 tests and the 21 of the socket calls that were done before L1 and never had a list (accept, bind, connect, listen, recv, recvfrom, send, sendto, socket; not socketcall, which x86-64 does not have).

Four steps:

  1. The BSD calls on IP sockets: getsockname, getpeername, accept4, the type flags of socket, listen that binds what nobody bound, socket options as Linux reports them (SO_TYPE, SO_ERROR, SO_DOMAIN, SO_PROTOCOL, SO_ACCEPTCONN, the buffer sizes, SO_LINGER, the timeouts, TCP_NODELAY), the flags of a send and a receive (MSG_DONTWAIT, MSG_PEEK, MSG_NOSIGNAL), a receive that waits as long as Linux's, readv and writev on a socket, and sendmsg, recvmsg, sendmmsg, recvmmsg.
  2. AF_UNIX stream sockets, a file type of their own beside the pipe: a name in the filesystem or in the abstract namespace, listen, connect, accept, socketpair, poll, shutdown, the names and SO_PEERCRED.
  3. AF_UNIX datagram sockets and SCM_RIGHTS: messages kept whole, a connected or a named peer, and open file descriptions passed with a message - A3's counted descriptions, installed in the receiver's table.
  4. The programs: wget fetching from the host, a lookup through musl's resolver at boot, a client and a server over a Unix socket; and LTP for the phase.

Steps 1 to 3 (2026-10-06). A socket is a description whose type carries a second table, vibeos_sock_ops_t (include/vibeos/sockops.h): bind, listen, connect, accept, sendmsg, recvmsg, shutdown, the names and the options, in neutral terms - an address is vibeos_sockaddr_t, a message vibeos_msg_t with its vector, its flags and the descriptions it carries. Two types fill it: IP (kernel/abi/files/socket.c, over the stack) and local (kernel/abi/files/unixsock.c). The Linux handlers (kernel/abi/linux/net.c) only translate: sockaddr, msghdr, cmsg and mmsghdr in and out, through two helpers that judge each user range before it is copied. The options every socket keeps alike - the buffer sizes, doubled as Linux reports them, the timeouts, SO_LINGER, the flags - live in the description (kernel/abi/files/sockopt.c); a type answers only its own (SO_ACCEPTCONN, SO_ERROR, SO_PEERCRED, FIONREAD's count).

  • The stack learned what the calls ask of it: a bind to an address and a port nobody holds (EADDRINUSE, SO_REUSEADDR as Linux has it for TCP and UDP), an ephemeral port for port 0 and for a listen nobody bound, a connected UDP peer, MSG_PEEK and a datagram's whole length for MSG_TRUNC, and the names of both ends. The ten-second EIO of a recv went: a receive waits as long as Linux's, or SO_RCVTIMEO.
  • Local sockets are a table of endpoints, each with a page of ring. A stream is bytes; a datagram keeps its length and its sender's name in the ring with it. A name is a node in the filesystem - bind makes one with the new mknod operation, tmpfs has socket nodes, and opening one is ENXIO
    • or a run of bytes in the abstract namespace; binding no name autobinds five hex digits there, as Linux does. SO_PEERCRED is who connected, or who listened.
  • SCM_RIGHTS carries A3's counted descriptions. The sender's references travel with the message, tied to its place in the ring so a stream read stops at the byte they came with; the receiver installs what its control buffer has room for (MSG_CMSG_CLOEXEC honoured) and gives the rest back, with MSG_CTRUNC. Nothing is put under the socket table's lock: a description's last reference may be another local socket's, whose release takes that lock.
  • readv and writev on a socket are one receive or one send over the vector, not one per element - a datagram is not cut into pieces.

One thing found by running the step's program on Linux first: a datagram from a sender with no name arrives with a name of length 0, not the bare family getpeername returns. Both the host test and the program had assumed the family.

  • Sabotage: net-l5-inet.txt (3), abi-sockopt.txt (2), abi-unixsock.txt (4), abi-net-l5.txt (3), abi-socket-l5.txt (3), and the anchors of four older case files moved with the code they test. One went NOT RED - SHUT_WR not told to the peer - because the fake kernel hands back an abandoned wait as 0, the very value the end of a stream reads as; the check asks for the outcome now and the case goes red. One more was seen before it ran: the buffer-size check set 1000, whose double is under the floor too, so "not doubled" could not have shown; a second check sets 5000.

Step 4 (2026-10-06): the programs. tests/corpus/run-l5.sh, staged as /corpus/progs5.sh, runs at boot after L4's: BusyBox wget fetches a page from the host - a token the gate makes up for each run, so nothing but a fetch can print it (wget_not_fetched; the first boot of a run only, as the echo) - a lookup through musl's getaddrinfo goes to QEMU's resolver at 10.0.2.3 and from there to the host's (dns_not_resolved; asked only when the host can resolve the name itself, otherwise the verdict says dns_absent), and SOCKETS.ELF (tests/linux/musl_sockets.c, written and run on Linux first) checks the step's calls from ring 3, a Unix-socket server and client in two processes with a pipe passed back among them (sockets_l5_failed). The volume gained /etc/hosts and /etc/resolv.conf. All three worked on the first boot.

LTP for the phase - tests/corpus/ltp-l5.txt and the seven epoll tests L4 handed over, 64 - first measured 26 passed. Half of what failed was one thing: there was no loopback. Every LTP socket test talks to itself through 127.0.0.1, and the stack sent that to the gateway; a SYN nobody answered came back as ECONNREFUSED after the retries. The stack has one now: a packet for 127.0.0.0/8 or for the machine's own address waits in a ring of sixteen and the next poll - the timer's - feeds it to the input path, never at once, because the sender is in the middle of changing the socket the answer is matched against. 127.0.0.0/8 from the wire is a martian and dropped, and a SYN to a port nobody listens on is answered with a reset, so a refusal is immediate rather than a timeout. The rest were orders of refusal, each checked against Linux before it was changed: getsockopt at a level a socket has not is EOPNOTSUPP (setsockopt's is ENOPROTOOPT); accept on a socket that does not listen is EINVAL before its buffer is looked at; getsockname with no length is EFAULT; a vector length negative as a size is EINVAL before any base is judged (readv and writev on anything); MSG_ERRQUEUE finds nothing; a stream's recvfrom never writes a source, so a bad one is no error, though a negative length still is EINVAL; SO_SNDBUFFORCE and SO_RCVBUFFORCE are the superuser's. After: 44 passed (40 in the full run, then recv01, recvmsg01, sendmmsg01 and recvfrom01 run again after the last of these), 1 failed, 13 broken, 6 not applicable. What is left, by owner:

  • Elsewhere: network namespaces (unshare, eight tests), SysV semaphores (sendmsg02), ip/ifconfig (sendmsg01), IPv6, SCTP and RDS, O_PATH (accept03, L1's open), two CPUs as sched_getaffinity counts them (writev03, L6), kTLS and multicast options (setsockopt10, accept02).
  • Open here: send02 opens a thousand connections in a row and needs MSG_MORE to hold data back - this stack sends at once and keeps a closed connection's slot four seconds, so the table of sixteen runs out first; recvmmsg01 is killed by SIGSEGV in its main process, not looked into.

The step's six boots found one more, older than the phase: a half-closed connection lost its slot under an open descriptor. A connection that reached TIME_WAIT armed the stack's four-second reclaim whether or not its owner had closed it, and one shut for writing with shutdown gets there with the descriptor open and the peer's last bytes unread. BusyBox nc -l does exactly that; under three boots at once it read more than four seconds late, read nothing, and the call counted sock_fd_aba=2 - L4's nc check had been green on timing. The reclaim waits for the owner's close now, in TIME_WAIT and after LAST_ACK alike, and t_loopback holds a half-closed client six seconds before it reads.

  • Sabotage: l5-programs-boot.txt (4 at boot, one per reason, all red); loopback and the reset in net-l5-inet.txt (the reset's went NOT RED first: the retries running out also ends as ECONNREFUSED, so the check asks how long it took), the orders in abi-net-l5.txt and abi-iov-lengths.txt. One more NOT RED had nothing to do with its test: the anchor of "getsockname takes a null length" also matched the same condition in linux_sa_out, which comes first, and sabotage.py breaks the first. check-sabotage-anchors.py now counts anchors found twice in their target; seven older ones are, and the count may only go down.

L5 is closed (2026-10-06).

L6. Threads and scheduling (20, plus futex and clone finished)

The rest of futex (requeue, wake-op, the futex2 calls; priority inheritance if a program needs it), robust lists, CPU affinity, the sched_* parameters mapped onto the scheduler's classes, membarrier, getcpu.

Programs: glibc's pthread test programs, a thread-pool workload across four cores; LTP's futex and sched cases.

The plan (2026-10-09). Measured first: tests/corpus/ltp-l6.txt, the phase's 59 tests from ltp-list.py L6 and the eight of clone3, close_range, sched_yield and set_robust_list, which earlier phases own. On the parent (8c6d6c1) 16 of 67 pass. Thirty-four end in TCONF because the call is missing - the whole sched_* family, getcpu, ioprio, membarrier, get_robust_list, futex_waitv - and two more because futex answers ENOSYS to the bitset and requeue operations. The rest: futex_wake02, futex_wake03 and futex_cmp_requeue01 cannot create the threads and processes they ask for on a table of 32 slots; clone's vfork (clone05), CLONE_PARENT (clone08) and one flag combination (clone02) are refused; clone04 dies of SIGSEGV; set_robust_list accepts a length that is not its structure's.

Four steps:

  1. The rest of futex: WAIT_BITSET and WAKE_BITSET with their absolute timeouts on either clock, REQUEUE and CMP_REQUEUE, WAKE_OP; the futex2 calls (futex_wait, futex_wake, futex_requeue, futex_waitv); robust lists - set_robust_list checking its length, get_robust_list, and the list walked at a thread's exit, every lock it held marked OWNER_DIED and one waiter woken. Priority inheritance only if a program asks for it.
  2. Scheduling: the sched_* calls over the scheduler's classes and weights - SCHED_OTHER and SCHED_BATCH in NORMAL, SCHED_IDLE at the smallest weight, SCHED_FIFO and SCHED_RR in a real-time class between the kernel's and NORMAL, ordered by priority; sched_setaffinity and getaffinity over the mask the picker already honours; getcpu; sched_rr_get_interval from the class's quantum; ioprio_set and get kept and reported; membarrier.
  3. clone, finished: CLONE_VFORK, CLONE_PARENT, the flags clone02 and clone04 use, close_range's UNSHARE in a process with threads, and a task table large enough for what the tests and the thread pool create.
  4. The programs: glibc's pthread programs and a thread pool across four cores, at boot and against Linux.

Step 1 (2026-10-09): futex, finished. Every operation but priority inheritance, in kernel/abi/linux/futex.c:

  • A sleeper on several words. A waiter's table entry points at its sleeper's first, the leader, where "woken" and "by which word" are kept, so futex_waitv is the same wait as FUTEX_WAIT with more entries, and a wake that matches any of them counts once. Enqueueing compares every word under one hold of the lock, as a single wait always did; the sleep is a separate step that needs only the leader, so a 128-word vector is keyed into static arrays and released before the wait. The table is 512 entries (LINUX_FUTEX_TABLE, renamed from LINUX_FUTEX_WAITERS, which is a bit in a lock word to Linux).
  • Bitsets, requeue, WAKE_OP. A waiter keeps its bitset and a wake reaches only those it shares a bit with. Requeue rekeys entries in place - a moved waiter sleeps on, woken by the second word. WAKE_OP changes its second word with a real compare-exchange on user memory: vibeos_uaccess_cmpxchg32 (uaccess.S), one lock cmpxchg with the copy's recovery point, behind ks_user_cmpxchg32. A read and a later write would lose another thread's atomic in between.
  • Absolute timeouts (WAIT_BITSET, futex2) are ticks since boot, which is what every clock here counts, CLOCK_REALTIME included.
  • Robust lists. set_robust_list checks its length, get_robust_list answers for a thread the caller could read, and exit walks the list before it clears the join word: a lock word still naming the thread is marked OWNER_DIED, its waiters bit kept, one waiter woken. Bounded by Linux's 2048. The head is a field of the task's identity, set by fork, clone and exec - and by the boot-time spawn, which had never cleared clear_child_tid either.
  • Found by LTP: futex_waitv01 - a vector word at address 0 was read, not refused. The copy survives a page that went away but does not ask whose it is, and a Linux program's null page is the kernel's, present. Every word the kernel reads to compare is judged first now.
  • Tests: t_futex_l6 in the handlers' host tests, and two stages of THREADS.ELF at boot: C6_ROBUST (a child process ends holding a shared robust mutex; the parent's lock is EOWNERDEAD) and C6_COND (a broadcast releases four waiters).
  • Sabotage: futex-l6.txt 9 of 9 red; futex-l6-boot.txt red (robust_lock_not_released_at_exit). Two first versions proved nothing and were rebuilt: WAKE_OP's "wakes nobody when the comparison fails" had nobody waiting, and C6_ROBUST's thread returned through musl's pthread_exit, which walks the list in user space - the stage passed with the kernel's walk removed. Ending the thread by the raw exit call instead corrupted musl's thread-list lock and hung the boot; a child process is the arrangement in which only the kernel can release the lock. futex-l6-cond-boot.txt records why C6_COND has no case: a requeue that moves nobody went NOT RED, so the stage does not depend on it.
  • LTP: 21 of 67 (16 before): futex_cmp_requeue02, futex_wait_bitset01, futex_waitv01 and 02, get_robust_list01. What is left is steps 2 and 3, the 32-slot task table (futex_wake02 and 03, futex_cmp_requeue01), and SysV shared memory (futex_waitv03, L7).
  • Validation: check.sh green, 6 of 6 boots. Three boots in parallel wedged once in the reclaim load while the host ran a game; one boot alone passed, and the six after it, with the host idle.

Step 2 (2026-10-11): scheduling. kernel/abi/linux/sched.c, over the scheduler's policy:

  • A REALTIME class in sched_policy.c, between the kernel's and NORMAL, with a priority 1-99 that orders the pick before virtual time and preempts a lower one at once. Moving a slot between classes starts its virtual time at the new class's floor, as an admission does. The torture's model learned it (rt_priority_inverted, and a seventh operation that changes class); with the priority order removed it fails 40 seeds of 40.
  • The policies: SCHED_FIFO and SCHED_RR are REALTIME, SCHED_OTHER and SCHED_BATCH NORMAL, SCHED_IDLE NORMAL too - the IDLE class is the cores' idle tasks, behind which a program could starve. FIFO is time-sliced like RR. The policy, reset-on-fork and the I/O priority are the task identity's; linux_sched_inherit hands them to a fork or a thread, with reset-on-fork honoured. RLIMIT_RTPRIO bounds an unprivileged caller. SCHED_DEADLINE is EINVAL - no admission control to decide it by - which is sched_setattr01's and sched_getattr01's failure.
  • Affinity, getcpu: over the mask the pickers already honoured. A caller that unpins itself from its core is off it when the call returns, as on Linux (LTP's getcpu01 asks at once).
  • ioprio: kept and reported per thread, refused as LTP expects - a data field of 8 is EINVAL, whatever newer kernels call its upper bits; NONE reads back as NONE.
  • membarrier: every command but RSEQ's, the barrier being the shootdown IPI to the cores running the address space (or each one running a user space, for GLOBAL), which is an interrupt and so serialises them.
  • At boot: THREADS.ELF's C6_SCHED pins a child to core 1 and requires it to run there, sends membarrier's barriers with another thread spinning, and reads SCHED_FIFO back - through the system calls, because musl's sched_setscheduler is ENOSYS on purpose (the first version measured that).
  • Sabotage: sched-l6.txt 8 of 8, sched-l6-policy.txt 4 of 4, sched-l6-boot.txt red. The priority-range check went NOT RED first: the policy refuses the same priorities later, so only the order of the errors (EINVAL before EPERM, for another user's task) shows the handler's check.
  • LTP: 48 of 67, then getcpu01 and ioprio_set02 fixed. Left: clone (step 3), the task table (futex_wake02, 03, futex_cmp_requeue01), SCHED_DEADLINE, and what is not this phase's - SysV shared memory (L7), capget (L9), cgroups, a loop device, /proc/sys. clone04 dies of SIGSEGV on Linux too: the musl it is built against writes to a NULL stack before the call.
  • Validation: check.sh green, 6 of 6 boots, 30 torture seeds.

Step 3 (2026-10-11): clone, finished.

  • A process on its parent's memory. CLONE_VM without CLONE_THREAD was ENOSYS, and with it musl's and glibc's posix_spawn, which are built on CLONE_VM|CLONE_VFORK. Such a child now gets a process state of its own - its descriptors, handlers and identity copied as fork copies them - and the parent's page tables rather than a copy (ks_share_aspace), with a copy of the region list to fault and unmap by. Nothing new was needed to keep the tables alive: exit already keeps an address space another task is running in, and exec already leaves it to whoever is. What this does not do: CLONE_FILES, CLONE_FS and CLONE_SIGHAND between two processes copy rather than share (the registry's note, and a gap test), and a child that maps or unmaps while its parent runs leaves the parent's region list behind - a vfork parent is not running, which is what this is for.
  • CLONE_VFORK keeps the calling thread in clone until the child execs (in_vfork, cleared by exec) or ends. CLONE_PARENT gives the child the caller's parent. A forking clone writes its tid words too: the parent's where it says, the child's into the child's own copy before it runs (ks_poke_u32, through the child's tables, breaking copy-on-write as its first store would), and keeps CHILD_CLEARTID. The stack and SETTLS clone is given are the child's. CLONE_SIGHAND without CLONE_VM is EINVAL, as Linux.
  • The task table is 64 slots, from 32 - as far as the scheduler's 64-bit runnable masks go. LTP's futex_wake02, futex_wake03 and futex_cmp_requeue01 ask for 55 threads, 55 children and 100 waiters, and still do not fit.
  • At boot: THREADS.ELF's C6_VFORK - a CLONE_VM|CLONE_VFORK child stores into its parent's memory before clone returns, and musl's posix_spawn runs this program again as its exec probe.
  • Sabotage: clone-l6.txt 5 of 5, clone-l6-boot.txt 2 of 2. The host test of shared memory went NOT RED first: the fake's kf_ualloc arena is in nobody's page tables, so a store there is seen by every task whether memory is shared or not; the test now stores through the child's tables and reads through the parent's.
  • LTP: 53 of 67. clone02, clone05 and clone08 pass.
  • Validation: check.sh green, 6 of 6 boots.

Step 4 (2026-10-12): the programs. tests/corpus/threadpool.c - a pool of four workers on a queue, a barrier, bounded semaphores, thread-local storage and pthread_once, and a cancelled pthread_cond_wait - is built by corpus-build.sh against musl statically and against glibc dynamically, run by the corpus in the guest and on Linux, and compared line for line (l1-expected.txt, refreshed with corpus-expect.sh). Its answers depend on nothing but the kernel: fixed work, fixed results, no core counts. musl's ran first time. glibc's found three defects, none in a call this phase wrote:

  • A fatal signal ended one thread. The default action of a signal that kills, a ring-3 fault and a forged signal frame all ended the thread that took them, where Linux ends the process. glibc's abort in the thread being cancelled (libgcc_s, which pthread_cancel loads with dlopen, was not on the volume) killed the main thread and left the cancelled one blocked in a futex; the machine, which leaves userland only once every user task has ended, never finished the boot. Every fatal end now goes through hw_task_kill_group, exit_group's mechanism with the signal kept as every thread's status. A sibling killed by a thread's exec is the exception (exec_killed): the first boot after the change failed THREADS_C5_EXEC, because that sibling's SIGKILL began a group kill that ended the exec'ing thread too. THREADS.ELF's C6_ABORT is the stage: a child aborts with a thread waiting, and nothing may answer to its pid afterwards.
  • The TLB quarantine leaked when full. A frame unmapped while another core runs the same address space is parked until that core has flushed, in 512 slots, and a frame that found them full was leaked by design (H-015). glibc gives a finished thread's stack back with madvise(MADV_DONTNEED) over two thousand pages, so a pool of four threads lost about fourteen hundred frames a boot (userland_frames_lost). Bisected by program - musl clean, glibc not - then by section of the program, then read off tlbq_overflow. A full quarantine now has the cores holding the space flush through the shootdown, releases the frame, and drains.
  • The mm lock's waiters did not answer a flush. The first boot with that fix wedged: the holder of the mm lock, in madvise, waited in the shootdown for two sibling threads that were spinning for the same lock, whose one-instruction interrupt window QEMU's TCG never took the IPI in. The spinlocks already answered a flush themselves; ks_mm_lock does now too. fork's shootdown under the same lock had the same exposure.
  • And libgcc_s.so.1 is staged with glibc's libraries, since ldd does not list what is opened with dlopen.
  • Sabotage: kill-group-boot.txt, exec-kill-boot.txt, tlbq-full-boot.txt, mm-lock-flush-boot.txt; ring3-fault.txt re-anchored; t_sigreturn_forged asserts the group kill on the host. All four go red for their own reason on an idle host.
  • Validation: check.sh green; boots 6 of 6. A first run of the same binary measured 0 of 6 - every boot wedged with cores in the disk drivers during the reclaim load, one ran out of time still printing - on a host running a game and, for two minutes, a recursive grep over the build tree. Read as the host's because it was the signature recorded the last time, and confirmed by running again with the host idle, not by the count.
  • And the nightly's corpus check, red since step 1: each C6 stage made THREADS.ELF ask for calls its checked-in needs did not list. They are measured again (corpus-measure.sh) and docs/abi/corpus.md rewritten. A boot program that gains a stage changes what the corpus says it needs, and only the nightly compares the two.

L6 is closed (2026-10-12). LTP: 53 of 67 from 16. What is left, and why: futex_wake02, futex_wake03 and futex_cmp_requeue01 need more than 64 tasks (the runnable masks are 64-bit); sched_setattr01 and sched_getattr01 use SCHED_DEADLINE, refused for want of admission control; clone04 fails on Linux too (its musl writes to a NULL stack before the call); and futex_waitv03 (SysV shared memory, L7), clone304 (unshare), clone09 (/proc/sys), close_range01 (a loop device), clone11 and sched_setscheduler04 (capget, L9), clone303 (cgroup v2) and futex_wake04 (hugetlbfs) are other phases' or not this kernel's. A CLONE_VM process shares memory but copies CLONE_FILES, FS and SIGHAND (the registry's note).

L7. IPC (21)

System V shared memory, semaphores and message queues; POSIX message queues; splice, tee, vmsplice.

Programs: LTP's ipc cases; a producer and consumer over SysV shared memory.

The plan (2026-10-12). Measured first: tests/corpus/ltp-l7.txt, the phase's 81 tests from ltp-list.py L7. On the parent (L6's close) none of the 81 passes: every one of the 21 calls answers ENOSYS. What the tests ask for besides the calls is a /proc that LTP's IPC library reads and writes - /proc/sysvipc/{msg,sem,shm} counted line by line, msgmni, shmmax, msg_next_id and /proc/sys/fs/mqueue/queues_max written and read back - so /proc grows its first writable files.

Six steps:

  1. What the three kinds share, and message queues (ipc.c): Linux's numbering, ipcperms and the owner rule, the *_STAT and *_INFO commands, the four msg calls; /proc's owner files.
  2. Semaphores (sem.c): semop whole or not at all, semtimedop, SEM_UNDO.
  3. Shared memory (shm.c): segments as unnamed tmpfs files, attachments as shared mappings.
  4. POSIX message queues (mqueue.c).
  5. splice, tee, vmsplice (splice.c).
  6. The programs: a producer and consumer over SysV shared memory and semaphores, with a round trip through each kind of queue, in the corpus.

The calls are new and touch nothing that runs today, so steps 1 to 5 were written together and validated as one run; step 6 had its own.

Steps 1 to 5 (2026-10-12): the calls. In five files under kernel/abi/linux/, over one lock (linux_ipc_lock), nothing slow under it - a message's text is copied through kernel pages outside it.

  • Numbers are Linux's: seq << 15 | index, the index handed out cyclically over at least 64 as Linux's idr has since 5.4 (a queue removed and made again gets the next number, 1 2 3 rather than 1 2 1, checked against WSL's kernel), and the sequence moving on when the cycle restarts. msg_next_id, sem_next_id and shm_next_id ask for a number; the next free index at or above it is given, and the setting is used up. Thirty-two objects of each kind are kept, and msgmni, semmni and shmmni say so.
  • Permissions are ipcperms - owner or creator, group or creator's group, others; the superuser passes - and removing or changing belongs to owner, creator and superuser.
  • Message queues: by type, the lowest at or below a bound, MSG_EXCEPT, MSG_NOERROR, MSG_COPY by position; a message taken for a buffer that faults is lost, as on Linux, which copies after the unlink too.
  • Semaphores: sets of up to 250; semop tries the list on a copy and changes the set only if nothing would wait; a waiter is counted on the one semaphore that stopped it; SEM_UNDO is kept per process and added back when its last thread leaves (linux_ipc_exit, from exit), SETVAL and SETALL clearing everybody's adjustment as Linux does.
  • Shared memory: a segment is a /tmp file nothing names, made as memfd_create's is (linux_hidden_file); an attachment is a shared mapping of it whose regions carry the segment's number plus one in backing_id. The attach count is not kept: it is read off every process's regions when asked (linux_shm_attached), because fork copies a region, exit and exec drop them and munmap can take one, and a counter beside them would be one more field each of those paths had to remember. IPC_RMID while attached marks SHM_DEST and makes the key private; the segment goes at the next shm call after its last detach. mremap carries the tag.
  • POSIX queues: a descriptor type; messages by priority, each a header page and pages of text the sender allocates, so a queue the superuser makes past the defaults costs only what is in it; mq_notify by signal - SIGEV_THREAD is a netlink socket's to deliver, and there is none.
  • splice and tee go through a kernel page and look into the pipe before they take from it (vibeos_pipe_peek), so a destination that takes less loses nothing.
  • /proc takes files from its owner (vibeos_procfs_extra_t) with their text and, for a tunable, what a write does. Writing follows Linux for the superuser: any file opens for writing, a non-tunable refuses the write with EIO, a name /proc does not have is ENOENT; the mount reads rw.
  • The registry took sixteen tables of rows and refused a seventeenth with a panic at boot - in the host tests, a panic outside any call and a wall of unrelated failures. It takes 32.

Host tests: t_ipc_msg, t_ipc_sem, t_ipc_shm, t_mqueue, t_splice; the layout of every structure compared with uapi. Sabotage: ipc-msg-l7, ipc-sem-l7, ipc-shm-l7, ipc-shm-mm-l7, mqueue-l7, splice-l7 - 30 cases, all red. Validation: check.sh green, boots 6 of 6, LTP 61 of 81.

Step 6 (2026-10-12): the programs. tests/corpus/sysvipc.c - a producer and a consumer over a sixteen-slot ring in a segment, three semaphores guarding it (SEM_UNDO on the ring's), then five messages through a SysV queue by type and four through a POSIX queue by priority - under musl statically and glibc dynamically, run by /corpus/run.sh and compared with what Linux printed. The phase's LTP run found one defect of its own: vmsplice into a pipe was writev, which waits until every byte is in, where Linux puts in what fits and returns the count - vmsplice01 offers 128 KiB to a pipe it drains itself, and waited for ever. And F_GETPIPE_SZ answers the pipe's capacity. Validation: check.sh green, LTP 64 of 81; boots 5 of 6, the failure L6's (below).

L7 is closed (2026-10-12). LTP: 64 of 81 from 0. What is left, and why: mq_notify01 and mq_notify03 use SIGEV_THREAD, which needs netlink; mq_open01 expects EINVAL for an empty name, which glibc says and the kernel

  • Linux's too, under musl - answers ENOENT; msgctl05, semctl08 and shmctl06 need the 32-bit time_high fields x86-64 does not have; shmctl05 needs remap_file_pages (deferred); shmget02 reads the kernel's .config; splice06 needs pipe-max-size, splice07 userfaultfd, splice08 and splice09 Linux 6.7. Two gaps were this kernel's and not this phase's, and both were closed the next day. A pipe held 4 KiB where Linux's holds 64: it is a ring of pages now, 64 KiB, resizable with F_SETPIPE_SZ up to /proc/sys/fs/pipe-max-size, with 64 pipes where there were 8 - and splice and tee, which read through a one-page bounce, had been bounding each read by the pipe's free room alone, which with a 64 KiB pipe would have written past the page; they move a page at a time now, and all the pipe holds up to the length asked, as Linux does (vmsplice01). And shmctl01's fork was refused by a per-task ceiling of eight children (too-many-children), raised to half the table; its count of attachments was then three of twenty, because /proc showed a child woken by a signal still as S - a waiter with a signal it will take is R now, as on Linux. LTP after both: 67 of 81. Left of the two: a sleep lasts at least a tick, so semop04's two thousand microsecond sleeps take twenty seconds and its timeout ends it; and splice05 (pipe to socket) still times out.

Open, and not L7's: C6_SCHED, about one boot in eighteen. A child of THREADS.ELF pinned to core 1 was still not on it after three seconds of sched_yield (exit 11). sched_setaffinity now says, when a second has passed and the caller is still off its allowed cores, what each of those cores is running (an INFO line, code 63). Over some thirty boots it has said: another user task, NORMAL or IDLE class, on most of them - so the caller was released at the end of its slice and waited in a busy core's queue. A latency, and open; the review tracker carries it. A first "fix" that changed the wait from ks_idle to ks_wait_tick was based on a wrong premise - both halt the core and neither schedules, the tick does - and was taken back.

L8. System administration (11, plus uname finished)

mount/umount2 over the mount table, chroot, pivot_root, reboot, sethostname, swapon/swapoff over the existing swap area, syslog over the kernel log.

Programs: an init that mounts its filesystems and switches root; BusyBox mount, dmesg, reboot.

L9. Security (6)

Capabilities (capget/capset) on top of L2's credentials, seccomp in filter mode, Landlock. This is where ../vision.md's security goals - isolation, privilege domains, sandboxing - become syscalls rather than host models.

Programs: a seccomp-sandboxed program killed for a forbidden call; LTP's capability and seccomp cases.


Deferred and refused

Deferred (50): NUMA, legacy AIO, io_uring, namespaces, the new mount API, fanotify, file handles, quotas, ptrace, key management, memory protection keys and the rest listed in syscalls.md. ENOSYS, counted as deferred. Each moves into a phase the day a corpus program asks for it.

Refused by decision (33): syscalls Linux itself removed or never implemented (uselib, create_module, _sysctl, tuxcall, ...), loading code into the kernel (init_module, kexec_load, bpf), raw hardware access (iopl, ioperm, modify_ldt), and rseq, whose ENOSYS the C library already handles. Each has its errno and its reason in the registry.

Order and dependencies

A0 and A1 first: they make every later step measurable. A2 before A3, because A3's open file description is the first thing the host tests will exercise. A4 depends on A3 (a directory descriptor is a file type). A5 can run beside any of them.

L1 needs A3 and A4. L3 is what unlocks glibc, so it comes before L6, whose target programs are glibc's. L4 needs A3's file types. L5's SCM_RIGHTS needs A3's descriptions. L9 needs L2's credentials. L7 and L8 are the least entangled and can move if a program asks earlier.

L8 waits (decided 2026-10-12, after L7). System administration is the last of a sequence the project's owner set, because what it administers has to be defined first: the Windows system calls (the second personality over vibeos/ksvc.h), the filesystem's hierarchy, the user interface, the whole of security (of which L9 is the Linux-facing part), the applications that will manage the system, and the rest of networking - then L8. The order is recorded in ../roadmap.md.

Where the refactor started from

The layer was built one syscall at a time as each program needed one, which is how 73 rows got written and is not how 373 will be. C4 (2026-09-21) gave it one table, one validation engine and one operation vocabulary; A1-A5 give the rows beneath that table a registry, a portable home, descriptors, paths and layouts that match Linux. The second syscall dispatcher docs/roadmap.md listed as an open decision was deleted in C3 and is not part of this plan.

Every Linux x86-64 syscall

All 373 Linux x86-64 numbers (0-334 and 424-461), each with the answer this kernel gives it and the phase of phases.md that owns it. Generated by scripts/dev/make-syscall-table.py from the registry, kernel/abi/linux_syscalls.def - the same file the kernel reads to answer a number with no row, and check-syscall-checks.py holds every row against. Edit the registry, not this page; check.sh fails while the two differ.

StateCountWhat the kernel answers
done237through its row
partial35through its row, with the gap named
missing17ENOSYS, and the boot gate fails naming the number
deferred50ENOSYS, and the boot gate fails naming the number
refused34its errno, counted as refused - expected, never a failure

Without a row, by phase:

PhaseSyscalls
L1 - files and paths0
L2 - processes, credentials, time0
L3 - memory0
L4 - event loops0
L5 - sockets0
L6 - threads and scheduling0
L7 - IPC0
L8 - system administration11
L9 - security6
D - deferred50
R - refused34

A partial row names its gap and the phase that closes it. VibeOS's own two calls, outside the Linux number space, are listed last.

NrSyscallStatePhaseErrnoRowNote
0readdone--fs.c
1writedone--fs.c
2opendone--fs.c
3closedone--fs.c
4statdone--fs.c
5fstatdone--fs.c
6lstatdone--fs.c
7polldone--poll.c
8lseekdone--fs.c
9mmappartialL3-mm.ca shared mapping of a file on a filesystem that keeps no pages (FAT) is refused: ENODEV
10mprotectdone--mm.c
11munmapdone--mm.c
12brkdone--mm.c
13rt_sigactiondone--sig.c
14rt_sigprocmaskdone--sig.c
15rt_sigreturndone--sig.c
16ioctlpartialL1-fs.cthe console's modes, size and process group, the random devices' entropy count, and the descriptor requests; ISIG, VTIME and the other terminal requests (TCFLSH, TIOCSCTTY, ...) are not honoured
17pread64done--fs.c
18pwrite64done--fs.c
19readvdone--fs.c
20writevdone--fs.c
21accessdone--names.c
22pipedone--fs.c
23selectdone--poll.c
24sched_yielddone--proc.c
25mremappartialL3-mm.conly a private anonymous mapping grows; a zero old length and MREMAP_DONTUNMAP are refused
26msyncdone--mm.c
27mincoredone--mm.c
28madvisepartialL3-mm.cMADV_DONTNEED leaves a private page of a file as the program left it, where Linux shows the file again
29shmgetdone--shm.c
30shmatdone--shm.c
31shmctldone--shm.c
32dupdone--fs.c
33dup2done--fs.c
34pausedone--sig.c
35nanosleepdone--misc.c
36getitimerdone--timer.c
37alarmdone--timer.c
38setitimerdone--timer.c
39getpiddone--proc.c
40sendfiledone--fs.c
41socketdone--net.c
42connectdone--net.c
43acceptdone--net.c
44sendtodone--net.c
45recvfromdone--net.c
46sendmsgpartialL5-net.cSCM_CREDENTIALS is read past and never delivered; more than 256 iovecs is EMSGSIZE
47recvmsgpartialL5-net.cSCM_CREDENTIALS is never delivered; more than 256 iovecs is EMSGSIZE
48shutdowndone--net.c
49binddone--net.c
50listendone--net.c
51getsocknamedone--net.c
52getpeernamedone--net.c
53socketpairdone--net.c
54setsockoptpartialL5-net.cSOL_SOCKET's options and TCP_NODELAY; IP-level options are ENOPROTOOPT
55getsockoptpartialL5-net.cSOL_SOCKET's options and TCP_NODELAY; IP-level options are ENOPROTOOPT
56clonepartialL6-proc.ca CLONE_VM process shares memory; FILES, FS and SIGHAND without THREAD are copied, not shared
57forkdone--proc.c
58vforkdone--proc.c
59execvedone--proc.c
60exitdone--proc.c
61wait4done--proc.c
62killdone--sig.c
63unamepartialL8-misc.cfixed answer
64semgetdone--sem.c
65semopdone--sem.c
66semctldone--sem.c
67shmdtdone--shm.c
68msggetdone--ipc.c
69msgsnddone--ipc.c
70msgrcvdone--ipc.c
71msgctldone--ipc.c
72fcntldone--fs.c
73flockdone--fs.c
74fsyncdone--fs.c
75fdatasyncdone--fs.c
76truncatedone--fs.c
77ftruncatedone--fs.c
78getdentsdone--fs.c
79getcwddone--fs.c
80chdirdone--fs.c
81fchdirdone--fs.c
82renamedone--names.c
83mkdirdone--fs.c
84rmdirdone--names.c
85creatdone--fs.c
86linkdone--names.c
87unlinkdone--fs.c
88symlinkdone--names.c
89readlinkdone--fs.c
90chmoddone--names.c
91fchmoddone--names.c
92chowndone--names.c
93fchowndone--names.c
94lchowndone--names.c
95umaskdone--fs.c
96gettimeofdaydone--timer.c
97getrlimitdone--limits.c
98getrusagepartialL2-limits.cCPU time only, all of it user time; no RSS, faults or switches
99sysinfodone--misc.c
100timespartialL2-timer.call CPU time is user time, and a thread that has exited takes its time with it
101ptracedeferredDENOSYSno program in the corpus asks; revisit when one does
102getuiddone--proc.c
103syslogmissingL8ENOSYS
104getgiddone--proc.c
105setuiddone--proc.c
106setgiddone--proc.c
107geteuiddone--proc.c
108getegiddone--proc.c
109setpgiddone--proc.c
110getppiddone--proc.c
111getpgrpdone--proc.c
112setsiddone--proc.c
113setreuiddone--proc.c
114setregiddone--proc.c
115getgroupsdone--proc.c
116setgroupspartialL2-proc.cthirty-two supplementary groups, where Linux keeps 65536: more is EINVAL
117setresuiddone--proc.c
118getresuiddone--proc.c
119setresgiddone--proc.c
120getresgiddone--proc.c
121getpgiddone--proc.c
122setfsuiddone--proc.c
123setfsgiddone--proc.c
124getsiddone--proc.c
125capgetmissingL9ENOSYSthe security goals of docs/vision.md
126capsetmissingL9ENOSYSthe security goals of docs/vision.md
127rt_sigpendingdone--sig.c
128rt_sigtimedwaitdone--sig.c
129rt_sigqueueinfopartialL2-sig.ca real-time signal sent twice before it is taken is delivered once: Linux queues each, this kernel keeps one per signal
130rt_sigsuspenddone--sig.c
131sigaltstackdone--sig.c
132utimedone--names.c
133mknodpartialL1-names.cregular files only: a FIFO, a socket or a device node is EPERM - no filesystem here holds one
134uselibrefusedRENOSYSremoved or never implemented by Linux itself
135personalitypartialL2-limits.ckept and reported; no flag changes what the kernel does
136ustatrefusedRENOSYSremoved or never implemented by Linux itself
137statfsdone--names.c
138fstatfsdone--names.c
139sysfsrefusedRENOSYSremoved or never implemented by Linux itself
140getprioritydone--limits.c
141setprioritydone--limits.c
142sched_setparamdone--sched.c
143sched_getparamdone--sched.c
144sched_setschedulerdone--sched.c
145sched_getschedulerdone--sched.c
146sched_get_priority_maxdone--sched.c
147sched_get_priority_mindone--sched.c
148sched_rr_get_intervaldone--sched.c
149mlockdone--mm.c
150munlockdone--mm.c
151mlockalldone--mm.c
152munlockalldone--mm.c
153vhangupmissingL8ENOSYS
154modify_ldtrefusedRENOSYSno LDT: 64-bit programs do not use one
155pivot_rootmissingL8ENOSYS
156_sysctlrefusedRENOSYSremoved or never implemented by Linux itself
157prctldone--proc.c
158arch_prctldone--proc.c
159adjtimexpartialR-timer.creads the clock's state; setting it is refused, EPERM, as clock_settime is
160setrlimitpartialL2-limits.cNOFILE, FSIZE, DATA, NPROC, CPU, AS, MEMLOCK and STACK are enforced; the rest are kept and reported
161chrootmissingL8ENOSYS
162syncdone--fs.c
163acctdeferredDENOSYSno program in the corpus asks; revisit when one does
164settimeofdaypartialR-timer.cjudges its arguments; setting the clock is refused, EPERM, as clock_settime is
165mountmissingL8ENOSYS
166umount2missingL8ENOSYS
167swaponmissingL8ENOSYS
168swapoffmissingL8ENOSYS
169rebootmissingL8ENOSYS
170sethostnamemissingL8ENOSYS
171setdomainnamemissingL8ENOSYS
172ioplrefusedREPERMno raw I/O port access from user space
173iopermrefusedREPERMno raw I/O port access from user space
174create_modulerefusedRENOSYSremoved or never implemented by Linux itself
175init_modulerefusedREPERMno loadable modules: code enters this kernel only by being built into it
176delete_modulerefusedREPERMno loadable modules: code enters this kernel only by being built into it
177get_kernel_symsrefusedRENOSYSremoved or never implemented by Linux itself
178query_modulerefusedRENOSYSremoved or never implemented by Linux itself
179quotactldeferredDENOSYSno program in the corpus asks; revisit when one does
180nfsservctlrefusedRENOSYSremoved or never implemented by Linux itself
181getpmsgrefusedRENOSYSremoved or never implemented by Linux itself
182putpmsgrefusedRENOSYSremoved or never implemented by Linux itself
183afs_syscallrefusedRENOSYSremoved or never implemented by Linux itself
184tuxcallrefusedRENOSYSremoved or never implemented by Linux itself
185securityrefusedRENOSYSremoved or never implemented by Linux itself
186gettiddone--proc.c
187readaheaddone--fs.c
188setxattrdone--names.c
189lsetxattrdone--names.c
190fsetxattrdone--names.c
191getxattrdone--names.c
192lgetxattrdone--names.c
193fgetxattrdone--names.c
194listxattrdone--names.c
195llistxattrdone--names.c
196flistxattrdone--names.c
197removexattrdone--names.c
198lremovexattrdone--names.c
199fremovexattrdone--names.c
200tkilldone--sig.c
201timedone--misc.c
202futexpartialL6-futex.cevery operation but priority inheritance, which is ENOSYS: no program here asks for it
203sched_setaffinitydone--sched.c
204sched_getaffinitydone--sched.c
205set_thread_arearefusedRENOSYSi386 thread storage; x86-64 programs use arch_prctl
206io_setupdeferredDENOSYSlegacy AIO: ENOSYS, and libcs fall back to threads
207io_destroydeferredDENOSYSlegacy AIO: ENOSYS, and libcs fall back to threads
208io_geteventsdeferredDENOSYSlegacy AIO: ENOSYS, and libcs fall back to threads
209io_submitdeferredDENOSYSlegacy AIO: ENOSYS, and libcs fall back to threads
210io_canceldeferredDENOSYSlegacy AIO: ENOSYS, and libcs fall back to threads
211get_thread_arearefusedRENOSYSi386 thread storage; x86-64 programs use arch_prctl
212lookup_dcookierefusedRENOSYSremoved or never implemented by Linux itself
213epoll_createdone--epoll.c
214epoll_ctl_oldrefusedRENOSYSremoved or never implemented by Linux itself
215epoll_wait_oldrefusedRENOSYSremoved or never implemented by Linux itself
216remap_file_pagesrefusedRENOSYSremoved or never implemented by Linux itself
217getdents64done--fs.c
218set_tid_addressdone--proc.c
219restart_syscalldone--timer.c
220semtimedopdone--sem.c
221fadvise64done--fs.c
222timer_createdone--timer.c
223timer_settimedone--timer.c
224timer_gettimedone--timer.c
225timer_getoverrundone--timer.c
226timer_deletedone--timer.c
227clock_settimerefusedREPERMthe clock is the timer's uptime; there is nothing to set it from
228clock_gettimedone--misc.c
229clock_getresdone--timer.c
230clock_nanosleepdone--misc.c
231exit_groupdone--proc.c
232epoll_waitdone--epoll.c
233epoll_ctldone--epoll.c
234tgkilldone--sig.c
235utimesdone--names.c
236vserverrefusedRENOSYSremoved or never implemented by Linux itself
237mbinddeferredDENOSYSNUMA: one node here
238set_mempolicydeferredDENOSYSNUMA: one node here
239get_mempolicydeferredDENOSYSNUMA: one node here
240mq_opendone--mqueue.c
241mq_unlinkdone--mqueue.c
242mq_timedsenddone--mqueue.c
243mq_timedreceivedone--mqueue.c
244mq_notifydone--mqueue.c
245mq_getsetattrdone--mqueue.c
246kexec_loadrefusedREPERMno kexec: the machine is booted by the bootloader, not by itself
247waitidpartialL2-proc.cstopped and continued children are never reported
248add_keydeferredDENOSYSno program in the corpus asks; revisit when one does
249request_keydeferredDENOSYSno program in the corpus asks; revisit when one does
250keyctldeferredDENOSYSno program in the corpus asks; revisit when one does
251ioprio_setdone--sched.c
252ioprio_getdone--sched.c
253inotify_initdone--inotify.c
254inotify_add_watchdone--inotify.c
255inotify_rm_watchdone--inotify.c
256migrate_pagesdeferredDENOSYSNUMA: one node here
257openatdone--fs.c
258mkdiratdone--fs.c
259mknodatpartialL1-names.cregular files only: a FIFO, a socket or a device node is EPERM - no filesystem here holds one
260fchownatdone--names.c
261futimesatdone--names.c
262newfstatatdone--fs.c
263unlinkatdone--fs.c
264renameatdone--names.c
265linkatdone--names.c
266symlinkatdone--names.c
267readlinkatdone--fs.c
268fchmodatdone--names.c
269faccessatdone--names.c
270pselect6done--poll.c
271ppolldone--poll.c
272unsharedeferredDENOSYSnamespaces: after the credential model
273set_robust_listdone--futex.c
274get_robust_listdone--futex.c
275splicedone--splice.c
276teedone--splice.c
277sync_file_rangedone--fs.c
278vmsplicedone--splice.c
279move_pagesdeferredDENOSYSNUMA: one node here
280utimensatdone--names.c
281epoll_pwaitdone--epoll.c
282signalfddone--events.c
283timerfd_createdone--events.c
284eventfddone--events.c
285fallocatepartialL1-fs.cthe size is guaranteed, not the space; only mode 0 and KEEP_SIZE
286timerfd_settimedone--events.c
287timerfd_gettimedone--events.c
288accept4done--net.c
289signalfd4done--events.c
290eventfd2done--events.c
291epoll_create1done--epoll.c
292dup3done--fs.c
293pipe2done--fs.c
294inotify_init1done--inotify.c
295preadvdone--fs.c
296pwritevdone--fs.c
297rt_tgsigqueueinfopartialL2-sig.cas rt_sigqueueinfo: one pending instance per signal
298perf_event_openrefusedRENOSYSno performance-counter interface: a facility this kernel does not have
299recvmmsgdone--net.c
300fanotify_initdeferredDENOSYSno program in the corpus asks; revisit when one does
301fanotify_markdeferredDENOSYSno program in the corpus asks; revisit when one does
302prlimit64partialL2-limits.cas setrlimit
303name_to_handle_atdeferredDENOSYSno program in the corpus asks; revisit when one does
304open_by_handle_atdeferredDENOSYSno program in the corpus asks; revisit when one does
305clock_adjtimepartialR-timer.creads the realtime clock's state; setting it is refused, EPERM, as clock_settime is
306syncfsdone--fs.c
307sendmmsgdone--net.c
308setnsdeferredDENOSYSnamespaces: after the credential model
309getcpudone--sched.c
310process_vm_readvdeferredDENOSYSno program in the corpus asks; revisit when one does
311process_vm_writevdeferredDENOSYSno program in the corpus asks; revisit when one does
312kcmpdeferredDENOSYSno program in the corpus asks; revisit when one does
313finit_modulerefusedREPERMno loadable modules: code enters this kernel only by being built into it
314sched_setattrpartialL6-sched.cSCHED_DEADLINE is EINVAL: no admission control here
315sched_getattrdone--sched.c
316renameat2partialL1-names.cRENAME_EXCHANGE and RENAME_WHITEOUT refused
317seccompmissingL9ENOSYSthe security goals of docs/vision.md
318getrandomdone--misc.c
319memfd_createpartialL3-fs.cno seals: MFD_ALLOW_SEALING is accepted and F_ADD_SEALS refused; the file has a name under /tmp while open
320kexec_file_loadrefusedREPERMno kexec: the machine is booted by the bootloader, not by itself
321bpfrefusedRENOSYSno in-kernel bytecode: a facility this kernel does not have
322execveatdone--proc.c
323userfaultfdrefusedRENOSYSno user-space fault handling: a facility this kernel does not have
324membarrierdone--sched.c
325mlock2done--mm.c
326copy_file_rangedone--fs.c
327preadv2partialL1-fs.cRWF_ flags refused
328pwritev2partialL1-fs.cRWF_ flags refused
329pkey_mprotectdeferredDENOSYSno program in the corpus asks; revisit when one does
330pkey_allocdeferredDENOSYSno program in the corpus asks; revisit when one does
331pkey_freedeferredDENOSYSno program in the corpus asks; revisit when one does
332statxdone--names.c
333io_pgeteventsdeferredDENOSYSlegacy AIO: ENOSYS, and libcs fall back to threads
334rseqpartialR-proc.cENOSYS on purpose: the libc takes its fallback
424pidfd_send_signaldone--sig.c
425io_uring_setupdeferredDENOSYSa second I/O model; after L4
426io_uring_enterdeferredDENOSYSa second I/O model; after L4
427io_uring_registerdeferredDENOSYSa second I/O model; after L4
428open_treedeferredDENOSYSthe new mount API: after L8
429move_mountdeferredDENOSYSthe new mount API: after L8
430fsopendeferredDENOSYSthe new mount API: after L8
431fsconfigdeferredDENOSYSthe new mount API: after L8
432fsmountdeferredDENOSYSthe new mount API: after L8
433fspickdeferredDENOSYSthe new mount API: after L8
434pidfd_opendone--sig.c
435clone3partialL2-proc.cas clone; no set_tid, no cgroup
436close_rangepartialL6-fs.cCLOSE_RANGE_UNSHARE in a process with threads refused: a thread cannot hold a table of its own
437openat2partialL1-names.cresolve: only NO_MAGICLINKS; BENEATH, IN_ROOT, NO_XDEV and NO_SYMLINKS answer ENOSYS
438pidfd_getfddeferredDENOSYSno program in the corpus asks; revisit when one does
439faccessat2done--names.c
440process_madvisedeferredDENOSYSno program in the corpus asks; revisit when one does
441epoll_pwait2done--epoll.c
442mount_setattrdeferredDENOSYSthe new mount API: after L8
443quotactl_fddeferredDENOSYSno program in the corpus asks; revisit when one does
444landlock_create_rulesetmissingL9ENOSYSthe security goals of docs/vision.md
445landlock_add_rulemissingL9ENOSYSthe security goals of docs/vision.md
446landlock_restrict_selfmissingL9ENOSYSthe security goals of docs/vision.md
447memfd_secretrefusedRENOSYSno secret memory areas
448process_mreleasedeferredDENOSYSno program in the corpus asks; revisit when one does
449futex_waitvdone--futex.c
450set_mempolicy_home_nodedeferredDENOSYSNUMA: one node here
451cachestatdeferredDENOSYSno program in the corpus asks; revisit when one does
452fchmodat2done--names.c
453map_shadow_stackrefusedRENOSYSno shadow stacks
454futex_wakedone--futex.c
455futex_waitdone--futex.c
456futex_requeuedone--futex.c
457statmountdeferredDENOSYSthe new mount API: after L8
458listmountdeferredDENOSYSthe new mount API: after L8
459lsm_get_self_attrdeferredDENOSYSno program in the corpus asks; revisit when one does
460lsm_set_self_attrdeferredDENOSYSno program in the corpus asks; revisit when one does
461lsm_list_modulesdeferredDENOSYSno program in the corpus asks; revisit when one does
1000netctldoneNATIVE-net.cVibeOS's own, outside the Linux number space
1001pageinfodoneNATIVE-mm.cVibeOS's own, outside the Linux number space

What the corpus needs

Generated by scripts/dev/corpus-report.py from the syscalls each corpus workload made on Linux (tests/corpus/needs/, measured by scripts/dev/corpus-measure.sh under strace) and the state of each syscall in syscalls.md. Phase A0 of phases.md. The workloads are listed in tests/corpus/workloads.txt.

A workload is ready when nothing it asks for is missing or deferred here - the two answers the boot gate fails on. A refused call is an answer by decision. A partial syscall does not count against it - it is served, with a named gap that may or may not be the case this workload reaches. Ready is necessary, not sufficient: running the workload on VibeOS, and LTP, are what say it works. The musl test programs the boot gate already runs are the check on this report: they must come out ready.

Workloads25, of which ready: 25
Distinct syscalls asked for85
... done78
... partial7: clone, futex, ioctl, mmap, prlimit64, rseq, uname
... missing or deferred0
... refused (answered by decision)0

Without a full answer, by phase - the order to write them in

PhaseSyscallStateAsked for by
L1 - files and pathsioctlpartial20: bb-disk, bb-find-grep, bb-grep-r, bb-identity, bb-ls, bb-metadata, bb-pipeline, bb-ps, bb-sh-script, bb-tar, bb-time, lua-script, musl-dynamic, musl-hello, musl-pie, musl-signal, musl-tfork, musl-threads, sqlite-file, sqlite-memory
L2 - processes, credentials, timeprlimit64partial16: bb-cp-mv-rm, bb-dd, bb-disk, bb-find-grep, bb-grep-r, bb-identity, bb-ls, bb-metadata, bb-mkdir-rm, bb-pipeline, bb-ps, bb-sed-inplace, bb-sh-script, bb-tar, bb-text, bb-time
L3 - memorymmappartial8: bb-dd, lua-script, musl-dynamic, musl-hello, musl-tfork, musl-threads, sqlite-file, sqlite-memory
L6 - threads and schedulingclonepartial13: bb-cp-mv-rm, bb-disk, bb-find-grep, bb-identity, bb-metadata, bb-mkdir-rm, bb-pipeline, bb-sh-script, bb-tar, bb-text, bb-time, musl-tfork, musl-threads
L6 - threads and schedulingfutexpartial2: musl-tfork, musl-threads
L8 - system administrationunamepartial11: bb-cp-mv-rm, bb-disk, bb-find-grep, bb-identity, bb-metadata, bb-mkdir-rm, bb-pipeline, bb-sh-script, bb-tar, bb-text, bb-time
R - refused by decisionrseqpartial16: bb-cp-mv-rm, bb-dd, bb-disk, bb-find-grep, bb-grep-r, bb-identity, bb-ls, bb-metadata, bb-mkdir-rm, bb-pipeline, bb-ps, bb-sed-inplace, bb-sh-script, bb-tar, bb-text, bb-time

Per workload

WorkloadAsks forDonePartialMissing
bb-cp-mv-rm4036clone prlimit64 rseq uname-
bb-dd2421mmap prlimit64 rseq-
bb-disk3227clone ioctl prlimit64 rseq uname-
bb-find-grep3429clone ioctl prlimit64 rseq uname-
bb-grep-r2421ioctl prlimit64 rseq-
bb-identity3328clone ioctl prlimit64 rseq uname-
bb-ls2623ioctl prlimit64 rseq-
bb-metadata3530clone ioctl prlimit64 rseq uname-
bb-mkdir-rm3228clone prlimit64 rseq uname-
bb-pipeline3227clone ioctl prlimit64 rseq uname-
bb-ps2421ioctl prlimit64 rseq-
bb-sed-inplace2624prlimit64 rseq-
bb-sh-script3429clone ioctl prlimit64 rseq uname-
bb-tar4136clone ioctl prlimit64 rseq uname-
bb-text3228clone prlimit64 rseq uname-
bb-time3732clone ioctl prlimit64 rseq uname-
lua-script1614ioctl mmap-
musl-dynamic97ioctl mmap-
musl-hello97ioctl mmap-
musl-pie65ioctl-
musl-signal76ioctl-
musl-tfork1612clone futex ioctl mmap-
musl-threads3329clone futex ioctl mmap-
sqlite-file2725ioctl mmap-
sqlite-memory1816ioctl mmap-

The LTP oracle

The Linux Test Project's syscall tests that build against musl (scripts/dev/corpus-build.sh; the list is tests/corpus/ltp-built.txt): 1331 tests covering 320 syscalls. Each is matched to the syscall its name starts with, longest name first. What they are for is the phase column: a syscall with tests here has a conformance oracle waiting for it; one without has to be proved by the corpus programs alone. Another 199 built tests have a name that starts with no syscall's (for example abort01, cacheflush01, confstr01, endian_switch01, epoll-ltp) and are not counted.

PhaseSyscalls in the phasewith LTP teststests
done236219898
L1 - files and paths8871
L2 - processes, credentials, time10941
L3 - memory5547
L4 - event loops000
L5 - sockets4418
L6 - threads and scheduling4416
L7 - IPC000
L8 - system administration121236
L9 - security637
D - deferred5039144
R - refused by decision381753

System files the corpus opens

Not syscalls, and just as necessary: ps needs nothing VibeOS lacks at the syscall level and still fails without /proc. Process ids are folded to <pid>. These are the requirements of L2's minimal /proc, and of the /etc files the credential model will need.

PathOpened by
/dev/sdcbb-disk
/dev/urandomsqlite-file
/dev/zerobb-dd
/etc/busybox.confbb-cp-mv-rm, bb-dd, bb-disk, bb-find-grep, bb-grep-r, bb-identity, bb-ls, bb-metadata, bb-mkdir-rm, bb-pipeline, bb-ps, bb-sed-inplace, bb-sh-script, bb-tar, bb-text, bb-time
/etc/groupbb-identity, bb-ls, bb-metadata, bb-tar
/etc/localtimebb-ls, bb-metadata, bb-time
/etc/passwdbb-identity, bb-ls, bb-metadata, bb-ps, bb-tar, sqlite-file, sqlite-memory
/proc/<pid>/bb-ps
/proc/<pid>/cmdlinebb-ps
/proc/<pid>/statbb-ps
/proc/mountsbb-disk
/proc/self/exebb-cp-mv-rm, bb-dd, bb-disk, bb-find-grep, bb-grep-r, bb-identity, bb-ls, bb-metadata, bb-mkdir-rm, bb-pipeline, bb-ps, bb-sed-inplace, bb-sh-script, bb-tar, bb-text, bb-time

The NT system services, one by one

Every Nt* function that is a system call in Wine 9.0's ntdll.spec (the ones marked -syscall; the Zw* names are the same calls) - 237 of them - and the step of windows.md that implements each, or why none does. The counterpart of syscalls.md for Linux, with one difference that the plan explains: the numbers are ours, because our own ntdll is the only caller, so this list names services rather than numbers. The rows get their numbers in kernel/abi/nt/ when the step that implements them lands, in this order.

A step owns a call when it implements every information class the step's tests use; a call that starts small (NtWriteFile to the console in W1, NtQueryInformationProcess with the classes a process's start needs) is listed where it first appears and grows in later steps. Wine's list is the source because it is what Wine's own ntdll exports as system calls - the services a program built against Wine's headers can reach - and the oracle is Wine's tests.

GroupCalls
W16
W245
W323
W432
W518
W612
W727
The registry26
Tokens and security descriptors18
Not planned30
Total237

W1 - a process starts and prints

What our own hello and ntdll's start-up need. NtWriteFile to the console only; W4 makes it a file call.

NtQueryInformationProcess, NtQueryPerformanceCounter, NtQuerySystemInformation, NtQuerySystemTime, NtTerminateProcess, NtWriteFile

W2 - objects, handles and waits

The object manager: namespace directories and links, the four synchronisation objects, timers, keyed events and the waits over all of them.

NtAlertThreadByThreadId, NtCancelTimer, NtClearEvent, NtClose, NtCompareObjects, NtCreateDirectoryObject, NtCreateEvent, NtCreateKeyedEvent, NtCreateMutant, NtCreateSemaphore, NtCreateSymbolicLinkObject, NtCreateTimer, NtDelayExecution, NtDuplicateObject, NtMakeTemporaryObject, NtOpenDirectoryObject, NtOpenEvent, NtOpenKeyedEvent, NtOpenMutant, NtOpenSemaphore, NtOpenSymbolicLinkObject, NtOpenTimer, NtPulseEvent, NtQueryDirectoryObject, NtQueryEvent, NtQueryMutant, NtQueryObject, NtQuerySemaphore, NtQuerySymbolicLinkObject, NtQueryTimer, NtQueryTimerResolution, NtReleaseKeyedEvent, NtReleaseMutant, NtReleaseSemaphore, NtResetEvent, NtSetEvent, NtSetInformationObject, NtSetTimer, NtSetTimerResolution, NtSignalAndWaitForSingleObject, NtWaitForAlertByThreadId, NtWaitForKeyedEvent, NtWaitForMultipleObjects, NtWaitForSingleObject, NtYieldExecution

W3 - memory

Private memory, sections and views. The cross-process forms of the virtual-memory calls arrive with W6, when there is a second process to name.

NtAllocateVirtualMemory, NtAllocateVirtualMemoryEx, NtAreMappedFilesTheSame, NtCreateSection, NtFlushInstructionCache, NtFlushProcessWriteBuffers, NtFlushVirtualMemory, NtFreeVirtualMemory, NtGetWriteWatch, NtLockVirtualMemory, NtMapViewOfSection, NtMapViewOfSectionEx, NtOpenSection, NtProtectVirtualMemory, NtQuerySection, NtQueryVirtualMemory, NtReadVirtualMemory, NtResetWriteWatch, NtSetInformationVirtualMemory, NtUnlockVirtualMemory, NtUnmapViewOfSection, NtUnmapViewOfSectionEx, NtWriteVirtualMemory

W4 - files, pipes and completion ports

Files over the VFS, with \??\C:\ and case-blind names; named pipes and mailslots are files to NT, and so is asynchronous I/O's completion port.

NtCancelIoFile, NtCancelIoFileEx, NtCancelSynchronousIoFile, NtCreateFile, NtCreateIoCompletion, NtCreateMailslotFile, NtCreateNamedPipeFile, NtDeleteFile, NtDeviceIoControlFile, NtFlushBuffersFile, NtFsControlFile, NtLockFile, NtNotifyChangeDirectoryFile, NtOpenFile, NtOpenIoCompletion, NtQueryAttributesFile, NtQueryDirectoryFile, NtQueryEaFile, NtQueryFullAttributesFile, NtQueryInformationFile, NtQueryIoCompletion, NtQueryVolumeInformationFile, NtReadFile, NtReadFileScatter, NtRemoveIoCompletion, NtRemoveIoCompletionEx, NtSetEaFile, NtSetInformationFile, NtSetIoCompletion, NtSetVolumeInformationFile, NtUnlockFile, NtWriteFileGather

W5 - threads, APCs and exceptions

NtAlertResumeThread, NtAlertThread, NtContinue, NtCreateThread, NtCreateThreadEx, NtGetContextThread, NtGetCurrentProcessorNumber, NtGetNextThread, NtOpenThread, NtQueryInformationThread, NtQueueApcThread, NtRaiseException, NtResumeThread, NtSetContextThread, NtSetInformationThread, NtSuspendThread, NtTerminateThread, NtTestAlert

W6 - processes and jobs

NtAssignProcessToJobObject, NtCreateJobObject, NtCreateUserProcess, NtIsProcessInJob, NtOpenJobObject, NtOpenProcess, NtQueryInformationJobObject, NtResumeProcess, NtSetInformationJobObject, NtSetInformationProcess, NtSuspendProcess, NtTerminateJobObject

W7 - the rest of what kernel32's tests ask

System information in full, time, locale and NLS data, atoms, identifiers, and the debugger interface kernel32:debugger measures.

NtAddAtom, NtAllocateLocallyUniqueId, NtAllocateUuids, NtCreateDebugObject, NtDebugActiveProcess, NtDebugContinue, NtDeleteAtom, NtFindAtom, NtGetNlsSectionPtr, NtInitializeNlsFiles, NtPowerInformation, NtQueryDefaultLocale, NtQueryDefaultUILanguage, NtQueryInformationAtom, NtQueryInstallUILanguage, NtQueryLicenseValue, NtQuerySystemEnvironmentValue, NtQuerySystemEnvironmentValueEx, NtQuerySystemInformationEx, NtRemoveProcessDebug, NtSetDefaultLocale, NtSetDefaultUILanguage, NtSetInformationDebugObject, NtSetSystemInformation, NtSetSystemTime, NtSetThreadExecutionState, NtWaitForDebugEvent

The registry - a service, not the kernel's

The plan keeps the registry out of the kernel: these rows forward to a registry service. Needed when kernel32's tests reach advapi32; planned then.

NtCreateKey, NtCreateKeyTransacted, NtDeleteKey, NtDeleteValueKey, NtEnumerateKey, NtEnumerateValueKey, NtFlushKey, NtLoadKey, NtLoadKey2, NtLoadKeyEx, NtNotifyChangeKey, NtNotifyChangeMultipleKeys, NtOpenKey, NtOpenKeyEx, NtOpenKeyTransacted, NtOpenKeyTransactedEx, NtQueryKey, NtQueryMultipleValueKey, NtQueryValueKey, NtRenameKey, NtReplaceKey, NtRestoreKey, NtSaveKey, NtSetInformationKey, NtSetValueKey, NtUnloadKey

Tokens and security descriptors - with the security plan

The roadmap's fourth item. Until then a process's token is a fixed answer - one user, an administrator - for whatever a test asks of it, and these calls give it.

NtAccessCheck, NtAccessCheckAndAuditAlarm, NtAdjustGroupsToken, NtAdjustPrivilegesToken, NtCreateLowBoxToken, NtCreateToken, NtDuplicateToken, NtFilterToken, NtImpersonateAnonymousToken, NtOpenProcessToken, NtOpenProcessTokenEx, NtOpenThreadToken, NtOpenThreadTokenEx, NtPrivilegeCheck, NtQueryInformationToken, NtQuerySecurityObject, NtSetInformationToken, NtSetSecurityObject

Not planned

Each refused with STATUS_NOT_IMPLEMENTED and counted, as an unassigned Linux number is. Reasons per group: LPC ports are the transport of Windows' own subsystem services, which this design does not have; transactions (KTM) and drivers have no counterpart; WoW64 and LDT entries are 32-bit programs, which the plan leaves out; NtCallbackReturn is the window system's return from a user-mode callback; the rest administer a Windows machine.

NtAcceptConnectPort, NtCallbackReturn, NtCommitTransaction, NtCompleteConnectPort, NtConnectPort, NtCreatePagingFile, NtCreatePort, NtCreateTransaction, NtDisplayString, NtInitiatePowerAction, NtListenPort, NtLoadDriver, NtRaiseHardError, NtRegisterThreadTerminatePort, NtReplyWaitReceivePort, NtRequestWaitReplyPort, NtRollbackTransaction, NtSecureConnectPort, NtSetDebugFilterState, NtSetIntervalProfile, NtSetLdtEntries, NtShutdownSystem, NtSystemDebugControl, NtTraceControl, NtUnloadDriver, NtWow64AllocateVirtualMemory64, NtWow64GetNativeSystemInformation, NtWow64IsProcessorFeaturePresent, NtWow64ReadVirtualMemory64, NtWow64WriteVirtualMemory64

The NT personality: Windows programs on VibeOS

The second ABI after Linux's. Decided on 2026-10-13, after the Linux plan's L7, and first of the six things ../roadmap.md puts before L8.

What was decided, and why

Our own ntdll.dll, and an NT personality in the kernel under it. A Windows program never makes a system call itself: it calls ntdll.dll, and the numbers ntdll uses change with every Windows build. So "Windows system calls" is not a table of someone else's numbers to honour, as Linux's was - it is a choice of who writes ntdll. We do, from scratch, as ReactOS and Wine did: its Nt* functions call a second table of rows in the kernel, numbered by us because our ntdll is the only caller, and those rows reach the kernel through include/vibeos/ksvc.h exactly as the Linux handlers do. That is what the services interface was kept personality-neutral for since docs/abi/ A2. No Microsoft binary is shipped or needed.

The alternatives, and why not: running Wine as a Linux program would bring Win32 almost for free on L1-L7 and give the kernel no Windows personality at all; honouring one real Windows build's syscall numbers under Microsoft's own ntdll ties the system to a build and to binaries that cannot be distributed.

Handles and objects live in the kernel. A portable object manager, with a handle table per process - the counterpart of A3's open file descriptions. WaitForMultipleObjects over events, mutexes, semaphores, threads and processes needs the kernel anyway, and a user-space subsystem service would be one more moving part on every wait. ../compatibility.md proposed a service outside the kernel; what stays outside is what does not need it - the registry, and later the window system.

The oracle is Wine's conformance tests. Wine's own tests of ntdll and kernel32 (dlls/*/tests in the Wine source), built with mingw-w64 into PE executables. Each is run first under Wine on the build host, which is what the answers are compared with - the corpus's rule, "compare with what the reference printed, not with what you expect". They are LGPL and used only as an oracle: nothing of Wine goes into VibeOS, which is MIT, and our ntdll and kernel32 are written without its code in front of us.

Unlike LTP, the tests cannot be the measure from the first day: they need a PE loader, an ntdll, a kernel32 and a C runtime before the first line runs. W1 gets a program of our own to print "hello"; from W2 on the tests measure.

The pieces

PieceWhereBuilt with
PE32+ loader: map, relocate, resolve imports against our DLLs, TEB and PEB (GS base), first threadthe kernel's exec layer, beside ELFthe kernel's compiler
NT rows: Nt* services, our numbering, a registry like linux_syscalls.defkernel/abi/nt/the kernel's compiler
Object manager: object types, per-process handle tables, waitskernel/ob/, portable, host-testedthe kernel's compiler
ntdll.dll, then kernel32.dll and a C runtimeuser/win/mingw-w64
The oracle: Wine 9.0's ntdll and kernel32 testsscripts/dev/winetest-build.sh, cached outside the treemingw-w64

The NT handlers follow the Linux handlers' rules: nothing under kernel/abi/nt/ names the architecture, every user pointer is judged before it is used, and check-abi-layering.py covers the directory from its first file.

Steps

StepWhatMeasured by
W0This plan; the oracle built and run under Wine on the host, its results recorded; the registry's formatthe list of tests per area, and Wine's own answers
W1PE32+ loader and process start; ntdll minimal (NtWriteFile to the console, NtTerminateProcess)a program of ours prints "hello"
W2Object manager and handles: NtClose, NtDuplicateObject, events, mutants, semaphores, NtWaitForSingleObject/Multiplentdll:om, ntdll:sync, kernel32:sync
W3Memory: NtAllocateVirtualMemory, Free, Protect, Query; sections and views; and, from the reviews, an address space shared as one object between CLONE_VM processes (M-100), a page cache that knows files by node so a view maps from its handle (M-106), kernel stacks in a region of their own with guard pages and #PF/#DF on IST stacks (M-108)ntdll:virtual, kernel32:virtual
W4Files: NtCreateFile, Read, Write, QueryInformationFile, directories; \??\C:\ onto the VFS, names without casentdll:file, ntdll:directory, kernel32:file
W5Threads and exceptions: NtCreateThreadEx, TLS, SEH through KiUserExceptionDispatcher, APCs, alertable waitsntdll:exception, ntdll:thread, kernel32:thread
W6Processes: NtCreateUserProcess, environment, command line, exit codeskernel32:process
W7kernel32 and the console, as far as the tests askthe rest of kernel32's tests

Each step is closed as an L-step is: host tests, sabotage cases, check, six boots, and the step's own tests from the oracle.

What this plan does not do

No graphics: user32, gdi32 and the window system wait for the user interface (the roadmap's third item). No registry in the kernel: when kernel32's tests need advapi32, the registry is a service of its own. No 32-bit programs (WoW64). No driver model. Each of those is a later decision, made when a test or a program asks for it.

W0 (2026-10-13): done

  • The oracle's source is Wine 9.0 (tests/corpus/sources.txt), the version Ubuntu 24.04 packages as wine64, so the tests and the reference answering them are one release. Its sha256 is pinned on first use: WineHQ signs its tarballs with PGP and publishes no digest.
  • ntdll has 25 test files and kernel32 34; kernelbase 4, msvcrt 13 and ucrtbase 8 come later, if a step needs them.
  • Built by Wine's own build system (scripts/dev/winetest-build.sh), configured without X and without any host library, making only the two test executables. Compiling the test files alone against mingw-w64 was tried first and does not work: they include headers Wine generates from IDL, Wine's headers and mingw's disagree on basic types, and they link against helpers that live in Wine's own import libraries. Host needs: mingw-w64, flex, bison, and wine64 for the answers.
  • The executables import user32 and advapi32 besides kernel32, ntdll and msvcrt, though nothing W1-W6 measures calls into the first two. VibeOS will need stubs of both that load and answer nothing, before W2 runs a single test.
  • Run under Wine on the host by scripts/dev/winetest-run-host.sh, which keeps every test's output and Wine's own summary line; a test's exit code is its failure count. The answers are kept in tests/corpus/winetest-wine9.txt.
  • Every service the plan has to provide is listed, one by one, in nt-services.md: Wine 9.0's 237 Nt* system calls, each given to a step, to the registry service, to the security plan, or refused with its reason.

What Wine answered (Wine 9.0 on WSL2, 2026-10-13)

58 tests: 25 of ntdll, 33 of kernel32. 52 end with no failure. Six do not, and VibeOS is compared with the answer, not with a pass:

TestWine's answerWhat it means for the plan
ntdll:exceptionstops at the 38th case of its fault table, no summaryW5 cannot take this file whole as its measure; its cases before the stop can
kernel32:virtualstops; 679 failures printed, the process that printed them never summarisedOn VibeOS since W3: no failure, then CreateProcessA (W6)
kernel32:loader48 failures, all one check of section protections (got 0x4, expected 0x8)W1's loader is compared per check, not per file
kernel32:process44 failures: console modes, a standard handle of a GUI program, one error codeW6
ntdll:wow644 failures32-bit programs: not planned
kernel32:console1 failureW7

A test that starts copies of itself prints one summary line per process; the run script takes the parent's (the largest) and calls a log whose printed failures outnumber its summaries incomplete - taking the last line, as the first version did, reported kernel32:virtual as a child's "0 failures".

W0 is done. Next: W1, the loader and a program of ours that prints "hello".

W1 (2026-10-14): done

A PE program built by mingw-w64 runs on our own ntdll, from a boot's shell like any program: /win/hello.exe checks its TEB through %gs, its PEB, and each W1 service, and prints NT_HELLO_OK through NtWriteFile. The gate requires the line.

What it took, in the order it was built:

  1. A %gs for each task (0d83e8e). The kernel kept its per-CPU block in GS.base for good and never ran swapgs; a Windows program reaches its TEB through %gs. Every entry swaps when GS.base is not inside the kernel image
    • deciding by GS.base, not the saved CS, so an iretq that faults after the exit's swap still finds the kernel's block - and every exit to ring 3 swaps back with interrupts off. Linux's ARCH_SET_GS works on top of it.
  2. A PE32+ parser (f2c598d, kernel/core/pe.c) that describes an image in the ELF parser's segment form, so the architecture places it with the mapper it has. Tested against an image mingw-w64 linked, compared with what mingw's objdump says of it.
  3. The NT personality: kernel/abi/nt_services.def (all 237 services, the number being the line), a registry checked against the rows at boot in both directions, a dispatcher that reads Windows' convention - four arguments in r10, rdx, r8, r9, the rest from the caller's stack at +0x28, judged before they are read - and the six W1 services. A service with no row is STATUS_NOT_IMPLEMENTED, counted and named on the console; the gate allows exactly one, the probe hello.exe asks for on purpose.
  4. Loading: exec of a file that starts MZ maps the program below the stack, /win/ntdll.dll above the heap, and five pages for the PEB, the process parameters and the TEB just below the mapping area; binds the task to the NT personality; and starts the thread at ntdll's entry with the PEB in rcx and the TEB as its %gs. Our ntdll resolves the program's imports against its own exports and calls its entry.
  5. Our ntdll (user/win/ntdll): one stub per service, generated from the same .def the kernel is checked against, and layout_check.c, which holds include/vibeos/nt_layout.h against mingw-w64's winternl.h and winnt.h at every build.

Decisions W1 made that later steps inherit, each said where it is made:

  • A handle is a descriptor for now: (fd + 1) * 4, so the standard handles are 4, 8 and 12. W2's object manager decides whether NT handles get a table of their own or share the descriptor table, which already holds typed, counted objects.
  • No relocation: an image is placed where its header says, inside the window it is allowed, or refused (pe_needs_relocation). Wine's test executables are linked at 0x140000000 and need it, so it is W2's first piece of work, before a single test runs.
  • /win is where the DLL and the program live, provisionally: the filesystem's hierarchy is the roadmap's next item.
  • One process, one thread: NtTerminateProcess and NtQueryInformationProcess answer for the caller only; the parent of an NT program is a Linux one, so the kernel builds the process parameters itself from argv until W6's NtCreateUserProcess.

Measured: host tests (nt_abi_tests.c, pe_tests.c), sabotage on the host (core-pe.txt 8/8, nt-dispatch.txt, nt-services.txt, nt-start.txt 13/13) and at boot (arch-gs-boot.txt 2/2, nt-hello-boot.txt 1/1), check, six boots.

Next: W2 - base relocation, stub user32 and advapi32 for the tests' imports, then the object manager, handles and waits, measured by ntdll:om, ntdll:sync and kernel32:sync.

W2 (2026-10-15 to 2026-10-17): done

What W0's measurement says W2 has to be. Wine's ntdll_test.exe is an eight-megabyte image linked at 0x140000000, and it imports 318 functions from five DLLs - 141 from kernel32, 113 from ntdll (most of them Rtl*, not system calls), 44 from msvcrt, 13 from advapi32, 7 from user32; kernel32_test.exe imports 612. So before the first test can run, three things the plan placed later have to come first:

  1. Base relocation. 0x140000000 is no user address here. The kernel places an image it cannot put where it asks at USER_BASE + 8 MiB (an NT process has no brk) and applies its relocations as each page is filled - one .reloc block per page, a value straddling two pages read from the file and written in halves. Tested against mingw's own image and objdump's list of its relocations; hello.exe is linked at mingw's default base, so every boot relocates it.
  2. Loading DLLs from disk. On Windows that is ntdll's loader calling NtOpenFile, NtCreateSection(SEC_IMAGE) and NtMapViewOfSection - so a minimal form of each comes forward from W3 and W4, and the image mapping is the kernel's, relocation included.
  3. DLLs that load before they are complete. A missing export is bound to a stub that, when called, prints unimplemented: kernel32.CreateFileA and ends the process. The tests then say which function to write next, as LTP did for Linux. msvcrt.dll - formatting, strings, a heap, argv - is written in full now, being the test harness's own runtime.

Handles are descriptors (decided here; W1 left it open). An NT handle names an entry of the process's existing descriptor table, (fd + 1) * 4; the entry's spare 32 bits hold the handle's access mask. Events, mutants, semaphores and sections become description types, as eventfd and pidfd already are, with an NT extension to their operations: whether the object is signalled for a waiter, and what acquiring it does. Reference counting, closing, fork and the wait machinery are then the ones seven phases of Linux work have tested, not a second set; named objects are a namespace of names onto descriptions.

Order: relocation; the object manager's core (types, NtClose, NtDuplicateObject, the namespace); NtOpenFile and image sections; ntdll's loader; events, mutants, semaphores and the waits; then ntdll:om and ntdll:sync run.

Done: base relocation (8cdb675)

hello.exe is linked at 0x140000000 and every boot moves it to USER_BASE + 8 MiB; the kernel's log says moved from 0x140000000.

Done: the object manager's core, events and waits (f8dfd6f)

NtClose, NtDuplicateObject, events and both waits, on the descriptor table with access masks and a flat namespace of whole paths. Host-tested, 19 sabotage cases red; no Windows program uses them at boot yet.

Done: msvcrt.dll

Wine's test executables import their C runtime from msvcrt.dll, so VibeOS ships one, written from the C standard and Microsoft's documentation and never from another implementation: user/win/msvcrt, a freestanding x64 DLL exporting exactly the 74 names the tests import (msvcrt.def), importing only NtWriteFile, NtTerminateProcess and NtQuerySystemTime from our ntdll. It is not loaded by anything yet.

  • The logic is portable C (crt_core.c, uint16_t for a wide character, int32_t for msvcrt's long) that mingw-w64 builds into the DLL and the host builds into vibeos_msvcrt_tests; msvcrt.c is the Windows part - the exported names, the three standard streams (__iob_func returns an array whose element size is checked against mingw's struct _iobuf), the heap's 16 MiB arena in .bss, the command line from the PEB.
  • msvcrt's printf, not C99's: %ld is 32 bits, %I64d/%Id exist, %p is sixteen upper-case digits, %n is refused, exponents have three digits, a tie rounds away from zero, infinities are 1.#INF, and _vsnprintf returns -1 for a truncated result and writes no NUL then. Where it agrees with C99 it is held against glibc's snprintf (about 5,950 generated cases per seed, the seed printed; floating point is expanded exactly and rounded once, which is the only way to agree at every precision).
  • Absent services are answered as a runtime with nothing behind it would: no files until W4 (fopen fails, fgets finds nothing), no environment block (getenv finds nothing), no locking until W5 (the heap, strtok's position and the generator's seed are statics).
  • Measured: vibeos_msvcrt_tests (3.2 million checks), a layout check against mingw-w64's headers at every build, check-msvcrt-exports.py (exports equal the .def, imports are ntdll.dll alone), and sabotage (cases/win-msvcrt.txt, 20 of 20 red).

It runs: every Windows program at boot and every Wine test loads it (below).

Done: image sections and ntdll's loader (326cbfc, 46a98a4, 042f560)

NtOpenFile maps \??\C:\ onto the VFS root; NtCreateSection(SEC_IMAGE) and NtMapViewOfSection place an image in the DLL window at 64 KiB granularity and relocate it, answering STATUS_IMAGE_NOT_AT_BASE when it moved. ntdll's loader brings DLLs in with them, depth first, runs DllMain, and binds an import no DLL exports to one of 2048 stubs that prints unimplemented: <dll>.<function> and ends the process. stubcall.exe calls one (Beep, which kernel32 will never have) and the gate asserts the line. Wine's executables have seventeen sections, so the image layout holds 24.

Done: mutants, semaphores, Sleep (45c97bc, a21935f)

On the events' wait: a mutant is its owner's to take again and to release, its count the signal state Windows reports; a semaphore refuses a release past its maximum. NtDelayExecution waits on the same clock. Abandonment has its state and status; the exit path that sets it is W5's.

Done: our kernel32 (3923ba0, ca080c1, 395ae7c)

user/win/kernel32 replaces the placeholder: the Win32 calls as Nt calls - named objects under \Sessions\<n>\BaseNamedObjects (the PEB says session 1, as Wine's does), the waits in milliseconds, Sleep, the tick count, GetModuleHandle, GetProcAddress and LoadLibrary through ntdll's Ldr*, the process heap (RtlAllocateHeap and its family, on the Rtl arena), the TEB's 64 TLS slots, the environment, the standard handles, WriteFile. Every failure sets the last error through RtlNtStatusToDosError, whose pairs are the ones Wine's error.c checks on Windows. Exports are kernel32.def's, checked against the built DLL.

Written ahead of the kernel, on purpose. Wine's tests for W2 reach files and threads early - ntdll:om a named pipe in its third test, both sync tests a thread in their second - and a stub ends the test there. So CreateFile and CreateThread exist now over NtCreateFile (W4) and NtCreateThreadEx (W5), which the kernel answers STATUS_NOT_IMPLEMENTED and counts: the test is told the call failed and goes on, its failures named, as a Linux program was told ENOSYS. This is the rule for the steps after W2 as well: a kernel32 function may be written before its service, never instead of it.

What the oracle says at the end of W2

scripts/dev/winetest-run.sh build-gcc-Release ntdll:om ntdll:sync kernel32:sync (2026-10-17):

TestWine on LinuxVibeOSWhere it ends
ntdll:om7,347 tests, 0 failuresat least 38 failuresCreateWaitableTimerA (a timer object: W5's)
ntdll:sync250 tests, 0 failuresat least 39 failuresa segmentation fault after the keyed-event tests (keyed events: W5's)
kernel32:sync354,502 tests, 0 failuresat least 1 failureTerminateThread (W5)

The counts are lower bounds: the gate keeps the last forty lines of each test, and a test that ends at a stub prints no summary. None of them finishes, and none can in W2: each reaches threads, timers or keyed events, which are W5's. What W2 promised - handles, events, mutants, semaphores, the waits - is what they now exercise, and the failures they report before the end are the work list for W5 together with the functions the stubs name. ntdll:rtlstr runs far past W2's scope and reports RtlDuplicateUnicodeString wrong for flags 2 and 3, then calls an Rtl function our ntdll does not export.

What these boots also showed. One winetest boot wedged after the tests, in the corpus shell, with SWAP_IN_FAILED on a garbage slot and two SWAP_DOUBLE_RELEASE: the open swap defect M-070 (review_findings_tracker.md), not something W2 wrote. The NT processes make it likelier: an image's .bss is filled when it is mapped, so ntdll_test.exe and its DLLs cost about ten megabytes each before a line runs, and reclaim swaps under that. Lazy .bss and a heap that grows are W3's, and W3 is where the pressure goes away. Evidence in .boot-evidence/w2-winetest/.

Closing W2

  • CI: two defects that every CI job saw and no local check did - the Rtl host tests did not build without mingw-w64 (41c2188), and stubcall.exe called a function kernel32 had meanwhile been given (0646d09). check.sh ran neither path: it now runs the whole of ctest (2a63723), and the stub's function is one kernel32 is never going to have.
  • Sabotage: host cases red for mutants, semaphores, delay, the heap functions and the status mapping; nt-loader-boot.txt run again after kernel32 - the stub returning goes red as named, unbound DLL imports red on the crash they cause, a missing export bound to address 0 hangs the boot instead of naming the fault.
  • Boots: check.sh all green; boots.sh 6 runs, 5 clean and one in which the gate died before booting - three gates on one efi_root each taking the previous winetest run's executables off, two of them the same file. Fixed in the gate, then 6 of 6 with those executables put back on purpose.

W3 (2026-10-20 to 2026-10-21): done

Measured by ntdll:virtual (Wine on Linux: 1,779 tests, 205 todo, 0 failures) and kernel32:virtual (Wine itself does not finish it: 679 failures printed, no summary - so the target there is to run as far as Wine does and fail where it fails). Twenty-three services are W3's in the registry; the ones the tests reach first are NtAllocateVirtualMemory, NtFreeVirtualMemory, NtProtectVirtualMemory, NtQueryVirtualMemory, and the data sections.

The mechanism W3 needs is one the memory manager was designed for and never built. VIBEOS_BACKING_ANON is documented as "zero-filled on first touch", and only the user stack is: mmap maps every page at once. Windows' model has two states mmap never needed:

  • reserved: address space set aside with no pages and no access - a touch is an access violation, and the range is not free for anybody else's mapping;
  • committed: pages promised, made on first touch.

Both are region states, not page-table states. CLAUDE.md records why: an earlier attempt to reserve without backing could not be told apart from a range munmap had just freed. A region that says what it is can be. And Windows asks per region for what it was allocated as - its allocation base and protection - which NtQueryVirtualMemory reports, and which therefore has to be kept.

Order, each a commit with host tests and sabotage cases:

  1. Demand-zero and reserved regions. A region flag for "reserved" and one for "committed, made on touch"; the page fault maps a zeroed page in a committed region as hw_stack_grow does for the stack - which becomes one case of it - and refuses a reserved one. Every path that walks entries (fork, munmap, mprotect, page-out, the range check, mincore) is asked what it does with a region that has no pages yet. The allocation base and protection ride on the region, and regions of different allocations never merge. Done: vibeos_vma_state_t and vibeos_vma_insert_alloc / vibeos_vma_set_state in kernel/mm/vma.c; hw_stack_grow serves a DEMAND region with the region's own protection and leaves a RESERVED one to fault, and hw_user_range_why accepts an untouched DEMAND page as it does an untouched stack page. munmap, page-out and mincore skip empty entries already; mprotect changes the region and the page takes it when made; fork copies the region whole. Nothing creates such a region before step 2, so the boot cannot see it yet - host tests and sabotage cover the region list.

  2. The four services, with their Windows rules: reservations at 64 KiB granularity, commits at page granularity inside a reservation, MEM_RESET, MEM_TOP_DOWN, release only of a whole reservation by its base, PAGE_* to region protection and back (PAGE_GUARD and PAGE_NOCACHE recorded), and MemoryBasicInformation as Windows fills it. kernel32's VirtualAlloc family over them. Done: kernel/abi/nt/memory.c, on kernel/abi/mem/umem.c - placing, releasing and RLIMIT_AS lifted out of Linux's mm.c, which asks the same questions. A region keeps the protection as Windows spelled it (user_prot), so PAGE_EXECUTE and PAGE_GUARD read back as given. kernel32 has VirtualAlloc, VirtualFree, VirtualProtect and VirtualQuery with their Ex forms. The fake kernel makes a committed page on first touch as the machine does. hello.exe exercises it at boot: a store into a page nobody touched, the kernel's store into another, a reservation committed a page at a time, protected, decommitted and released; sabotaging either half of the demand fault turns the boot red. Not enforced: PAGE_GUARD and PAGE_NOCACHE (recorded and reported), MEM_TOP_DOWN (placed as anything else), ZeroBits (refused: the arena is above 4 GiB), MEM_WRITE_WATCH and MEM_LARGE_PAGES (STATUS_NOT_SUPPORTED).

  3. A heap that grows. RtlCreateHeap and the process heap on NtAllocateVirtualMemory instead of ntdll's 512 KiB .bss arena; msvcrt's 2 MiB arena likewise. This is what makes an NT process cost what it uses. Done: the heap takes segments of at least a megabyte from NtAllocateVirtualMemory (vibeos_rtl_pages) when nothing on its free list fits, and never gives them back; a pointer is the heap's only inside a segment. msvcrt's malloc is RtlAllocateHeap on the process heap, as Microsoft's is, and its own arena is gone - its randomised soak now runs against ntdll's heap, with segments handed out apart, below the others and not at all. hello.exe mallocs three megabytes at boot and frees a malloc'd block with HeapFree (nt_heap_did_not_grow). HeapCreate - a heap other than the process's - is still to come.

  4. Lazy .bss for images. The mapper fills an image's zero-initialised tail as a committed demand-zero range instead of allocating it whole - ntdll_test.exe and its DLLs each cost about ten megabytes before a line ran, and the reclaim that caused is where the winetest boots met M-070. Done: a page no byte of the file reaches (vibeos_elf_page_all_zero) is a region made on first touch rather than a frame cleared at exec - in the high window only, where NT images and the native programs live. The low window, where Linux programs link, lies over the kernel's identity map: a page nobody mapped there is present for ring 0, a program's first store to it is a protection fault, and the first boot that deferred it there killed every static Linux program on its first store to .bss. A page a base relocation reaches is built at exec as before (vibeos_pe_page_relocated). mprotect takes an untouched page and mlock makes one, as Linux faults it in; the boot counts the pages left (pages_deferred) and fails at zero.

  5. Data sections. SEC_COMMIT sections backed by an unnamed tmpfs file (as SysV shared memory is), file-backed views, NtOpenSection, NtQuerySection, NtUnmapViewOfSection. Done: a section of memory alone is a file on /tmp nothing names, and its views map that file's own pages - one memory, however many views. A file's section is read-only or copy-on-write, because NtOpenFile opens for reading alone until W4, and its views are the process's own copies: a later change to the file does not show through them, which is the gap. A view is an allocation based at itself, bounded by the section's protection and the handle's rights, placed at 64 KiB; NtUnmapViewOfSection takes it whole from any address in it, as Wine's does, and NtFreeVirtualMemory refuses it. kernel32 has CreateFileMapping, OpenFileMapping, MapViewOfFile(Ex) and UnmapViewOfFile. hello.exe stores through one view and reads through another at boot (sec_shared). Not yet: SectionImageInformation, a view placed at an address for an image, unmapping an image's view, SEC_RESERVE as anything but SEC_COMMIT.

  6. Views from the handle's file (M-106's remainder). The page cache learns files by mount and node, so a view is mapped from the description its handle holds and never goes back to the name. Done: the mapper reads an image by the description's node (vibeos_file_node, getattr, never a lookup by name), and the page cache's identity is given for that node (hw_read_file_node); the look at the name stays for FAT, whose nodes are directory slots (M-106, fixed). A boot of this step also found M-117: kill(pid, 0) answered for a process whose last thread was on its way out.

  7. One address space for CLONE_VM processes (M-100). The region list, its lock and the break become one counted object that a CLONE_VM child shares with its parent instead of copying; threads already share it through the process state. Done: vibeos_mm_t, pointed at by the process state; 128 uses moved through it mechanically. A CLONE_VM child shares its parent's; exec makes a new one, as it always made a new process state (M-100, fixed).

  8. Kernel stacks with guard pages (M-108). A region of their own in every address space's kernel half, an unmapped page below each stack, and #PF and #DF on IST stacks so that an overflow faults on a stack that exists and the panic names the task. The canary stays as a second line. Done: 16 KiB stacks in windows of a region at 256 GiB, four guard pages below each, the double fault on an IST stack of its own. Not the page fault: its handler can wait and be switched away from, and a second fault on the same core would land on the IST stack the first still uses - the double fault takes the case where the page fault cannot be pushed, as on Linux (M-108, fixed).

  9. The oracle. ntdll:virtual and kernel32:virtual through winetest-run.sh, against Wine's answers; the work list they leave. Done. Its first run found three defects, fixed: ZeroBits refused an address the caller named before looking for the conflict there; a decommit of size zero was taken from anywhere in an allocation, where Windows takes it from the base's page alone and reports a size of zero; and kernel32's Create* read the new handle before the call that wrote it (C leaves the order of arguments to the compiler) - CreateFileMapping returned 0 having made its section. kernel32 gained GetSystemInfo.

    Where the two stand now:

    • ntdll:virtual: test_NtAllocateVirtualMemory passes whole; then test_NtAllocateVirtualMemoryEx fails seven checks on the placeholders (MEM_RESERVE_PLACEHOLDER, MEM_REPLACE_PLACEHOLDER, MEM_PRESERVE_PLACEHOLDER: Windows 10's way of splitting and refilling a reservation) and the process dies writing to memory a failed call did not give it.
    • kernel32:virtual: no failure, then CreateProcessA, which is W6's.

    The work list it leaves: NtAllocateVirtualMemoryEx and the placeholders; ZeroBits, which needs somewhere below four gigabytes for an NT process to allocate in - the user layout has none; CreateProcess with W6.

  10. NtAllocateVirtualMemoryEx and placeholders (2026-10-22), the first item of that list, held to Wine's own implementation where its test reads the answer. Done: nt_allocate is the body both allocation services share. MEM_RESERVE_PLACEHOLDER reserves a placeholder - no access, reserved, filled only by MEM_REPLACE_PLACEHOLDER, in place and whole; NtFreeVirtualMemory's MEM_PRESERVE_PLACEHOLDER turns all or part of a placeholder's allocation back into one, and MEM_COALESCE_PLACEHOLDERS joins two or more side by side. A region keeps the marks in its allocation's protection, above every PAGE_* bit (NT_ALLOC_PLACEHOLDER, NT_ALLOC_FREE_PLACEHOLDER), and an allocation split in two is regions given a base of their own (vibeos_vma_change_alloc). MEM_RELEASE now takes any range of pages inside an allocation, as Windows 10 and Wine do - test_NtFreeVirtualMemory and the placeholder test both ask it: the part above is an allocation based at itself, the part below keeps its base. kernel32's VirtualFree still refuses a release with a size, as Windows' does. The extended parameters are read as Wine reads them: at most one of each type, MEM_ADDRESS_REQUIREMENTS honoured by placing between the floor and the ceiling (vibeos_umem_place_in), at the alignment asked. Not done: a requirement the arena cannot meet - anything ending below its start, above four gigabytes - is STATUS_NO_MEMORY, the ZeroBits gap again; MEM_WRITE_WATCH is STATUS_NOT_SUPPORTED (NtGetWriteWatch is missing); ARM64EC code is refused, as on an x86-64 Windows. VirtualAlloc2 is not added: Windows exports it from kernelbase through an API set, which our DLLs do not have.

    Where ntdll:virtual stands now: test_NtAllocateVirtualMemory passes whole; test_NtAllocateVirtualMemoryEx fails one check, the write watch (line 412); test_NtAllocateVirtualMemoryEx_address_requirements fails ten, every one an allocation asked to end below 0x30000000 (and the frees of what that did not allocate); test_RtlCreateUserStack is skipped (missing); test_NtMapViewOfSection cannot create its test file and the process stops at kernel32's SetFilePointer, which is W4's. The list left: a low arena for ZeroBits and address requirements, write watches, RtlCreateUserStack, and files.

Steps 1 to 5 are the NT personality's; 6 to 8 are the reviews' findings that need the same memory work, scheduled here on 2026-10-19.

What the step boots found that was not the step's: M-117 (kill(pid, 0) and a thread on its way out, fixed in part and reopened when it recurred) and M-070 again, an end-of-boot swap slot in a swap regime the 16 KiB kernel stacks moved the boot into; both in the tracker, the second now instrumented.

W4 (from 2026-10-25): files

Measured by ntdll:file, ntdll:directory and kernel32:file. Twenty-six services are W4's in the registry; before it, NtOpenFile opened an existing file to read and NtWriteFile wrote at the file's own position, which is what loading a DLL and printing to a console need.

What W4 has to decide is how Windows' file model sits on the VFS. A handle is a description already (W2), so most of this is translation: dispositions and options onto create and truncate, the information classes onto stat and the description's position, and Windows' names onto the VFS's. The two places where the models differ, and which each step below has to answer for:

  • names: Windows compares without case, and a filesystem under C: may not; the walk has to find Foo when asked for FOO on tmpfs as FAT already does;
  • sharing: Windows refuses an open that conflicts with another handle's share mode; nothing in the VFS keeps share modes yet.

Order, each a commit with host tests and sabotage cases:

  1. NtCreateFile, and NtOpenFile complete. The six dispositions, the access rights onto the description's mode, DIRECTORY_FILE / NON_DIRECTORY_FILE, DELETE_ON_CLOSE, the synchronous-I/O options, and the status block's Information saying what happened (opened, created, overwritten, superseded).
  2. NtReadFile and NtWriteFile complete. A byte offset, the two special offsets (the file's position; the end of the file), STATUS_END_OF_FILE, and an event set on completion. Completion is always synchronous here; an APC is W5's, with threads that can be alerted.
  3. NtQueryInformationFile and NtSetInformationFile. Basic, Standard, Position, EndOfFile, Allocation, Disposition, Rename, Link, Name, Attribute tag, Network open, All.
  4. The small services. NtQueryAttributesFile, NtQueryFullAttributesFile, NtQueryVolumeInformationFile, NtFlushBuffersFile, NtDeleteFile, and NtCancelIoFile* (nothing is ever pending).
  5. NtQueryDirectoryFile. The directory classes, a mask with * and ?, restart and single-entry.
  6. Names without case on every filesystem under C:, and names beyond ASCII (UTF-16 to UTF-8 and back).
  7. Share modes and byte-range locks. The share check at open, NtLockFile and NtUnlockFile on the record-lock table (kernel/fs/filelock.c).
  8. kernel32's file API over the services: CreateFile, ReadFile, WriteFile, SetFilePointer(Ex), GetFileSize(Ex), the Find* family, DeleteFile, MoveFile, CreateDirectory, GetFileAttributes(Ex) and their A forms.
  9. The oracle. The three tests staged and run at boot, and what they say recorded here.

Done 2026-10-26 (check.sh all green with clang, 6/6 boots). Each step is a commit with host tests and sabotage cases, all red. What the oracle says at the end of W4: ntdll:directory runs to its end (2707 checks, 70 failures; Wine 4328 and none); ntdll:file stops at QueueUserAPC, which is W5's; kernel32:file at LocalAlloc. What W4 left open: NtLockFile does not wait (LOCK_NOT_GRANTED whether or not the caller would) and keeps no Key; names fold ASCII only; a DLL's GetModuleFileName is refused until the loader's list is asked. Seventeen services the registry still gives W4 are MISSING and move on: named pipes and mailslots, I/O completion ports, extended attributes, scatter/gather, device and filesystem controls, change notification, NtCancelSynchronousIoFile and NtSetVolumeInformationFile - none of them was on the oracle's path.

Storage and I/O: plan

Status: Proposed Author of record: written after the memory-manager rewrite closed, and grounded in a survey of what is actually there rather than in what the file names suggest.

Split across five documents, the same rule this plan asks of the code — each covers one concern:

DocumentCovers
architecture.mdthe layers, their contracts, and where the boundaries go
observability.mdevery counter and event, including for layers that do not exist yet
maintainability.mdthe structural rules, how they are enforced, and how each phase is verified
phases.mdI0 to I7, specified to where implementing them is mechanical
decisions.mdthe risks, and the questions reserved to the maintainer

1. What is actually there

A survey, not an impression. These are the facts the plan is built on.

About 2400 lines of filesystem code have never touched a real disk. kernel/fs/ holds ext2, NTFS, ISO9660, exFAT, a journal, a partition table reader, a storage layer and a block cache. Exactly one filesystem is mounted at boot, and it is not any of them: it is FAT, which lives somewhere else entirely, in kernel/arch/x86_64/fat.c.

This project already has the lesson written down — "the driver nobody runs is the one that ships broken" — from the AHCI gap, where the kernel could only talk to virtio-blk and every appliance imported, booted, and then failed every exec because there was no way to read the disk. That was one driver. This is seven components.

There are three parallel read paths, and they share nothing.

pathused bycache
blk_read → fat.c staticsthe boot filesystemtwo static sector buffers
blk_read → kernel/fs/blockcache.cext2, NTFS, ISO9660, exFAT, journal, storagea real block cache, unreachable at boot
hw_read_file_cached → the mm page cacheexecvethe page cache from mm P4

FAT — the only filesystem that runs — is the one that bypasses the block cache.

There is no I/O observability at all. Not one counter. The memory manager now carries roughly twenty, most of them asserted by the boot gate; the storage path carries none, so "the disk is slow", "the disk is retrying" and "the disk is fine" are the same silence.

Writes happen and nothing checks them.

This entry originally said no write is ever exercised. That was wrong, and the counter added in I1 disproved it within minutes of existing: a boot performs thirty sector writes. The shell's mkdir DOCS reaches vibeos_fs_mkdir, which reaches FAT, which writes both copies of the table.

The accurate statement is narrower and still worth acting on: writes reach the medium, and nothing verifies that what was written is what comes back. No read-back, no comparison, no assertion, and nothing at all across a reboot - which is the only check that distinguishes a write that reached the disk from one that reached a cache. I4 is about that, not about making writes happen.

Worth keeping as a note on method: this was a claim made from reading the code and grepping for callers, and it survived being written into a plan. What caught it was a number. That is the argument for the observability this plan asks for, demonstrated on the plan itself.

An error has no reason. blk_read returns int. Zero or not zero. A timeout, a device that is absent, a sector the medium cannot return and a request that was never issued are indistinguishable at every layer above.

One number means two things, and it is currently blocking the memory manager. hw_read_file_cached returns a count that is both "how long the file is" and "how many bytes I put in your buffer". They are equal only because the buffer is large enough to hold every file, which is why execve still carries 6 MiB of staging buffers that nothing needs. mm's P4 step 3 stops there and says so.

1b. Two different things are called a journal, and only one of them exists

kernel/fs/journal.c is a write-ahead journal: descriptor and commit blocks, magics VBJ1 and VBJC, the mechanism that makes a filesystem update survive a power cut. That is journaling in the ext3 or NTFS sense.

It is not the operating system writing its own log to disk. That does not exist at all. The kernel log is a ring in memory plus whatever reaches the serial port, and none of it survives a reboot.

The distinction is written down here because it has already caused one misunderstanding, and because the second thing is a real gap in a project whose entire debugging method is reading logs. Every hard defect in this codebase was found from a serial capture; a machine that wedges without one leaves nothing behind. That is phase I5b, and it is not a nice-to-have here in the way it might be elsewhere.

2. The goal

The same four the memory manager was held to.

Production ready. A machine that survives a disk that is slow, absent, or returning errors — and says which. Today a failing read is indistinguishable from a missing one, and a short read once reported itself as a complete file: execve then parsed whatever the previous program had left in the shared buffer. That defect is fixed; the shape that allowed it is still everywhere.

Observable. Every layer counts what it did and what it refused, in numbers the boot gate asserts. The rule this project arrived at the hard way applies unchanged: a number that is written and never read is not observability, and three separate subsystems have shipped one.

Maintainable. One cache, not three. One request type, not a different signature per driver. A boundary that a check can enforce, as check-exec-layering.sh did for the interpreter path.

Extensible. Adding a driver should be filling in a table. Adding a filesystem should be filling in vibeos_fs_ops_t and nothing else. Neither is true today: a driver has to be bound through four function pointers with three different shapes, and a filesystem has to choose which of the three read paths it belongs to.

3. What this plan explicitly does not do

It does not add features to the filesystems. ext2 gaining symlinks or NTFS gaining compression is not in scope and would be building on ground that has not been walked on.

It does not make I/O asynchronous before it makes it correct. Queueing and completion interrupts are I6, after the contract, the cache and the writes — and the reason is recorded in decisions.md, because doing it earlier is the tempting order and the wrong one.

Architecture — the layers and where the boundaries go

Five layers. The test of a boundary is whether a layer can be host-tested without the one below it, and the memory-manager rewrite is the evidence that this is worth insisting on: everything in that subsystem which could not be host-tested is where the defects were.

  B4  files            vibeos_fs_ops_t: FAT, ext2, NTFS, ISO9660, exFAT
  B3  volumes          partitions, mounting, which filesystem is on what
  B2  block cache      one cache, shared, the only place a sector is held
  B1  block devices    a request, a completion, and a reason when it fails
  B0  drivers          virtio-blk, AHCI, and whatever comes next

Today B4 is split across two directories, B2 is bypassed by the only filesystem that runs, and B1 is four function pointers with three shapes.

B0 — drivers

A driver registers a table and nothing else:

typedef struct vibeos_blk_driver {
    const char *name;
    uint32_t    sector_bytes;     /* 512 today; not assumed to be */
    uint64_t    sectors;          /* how big the device is */
    int  (*submit)(void *ctx, vibeos_blk_request_t *req);
    void *ctx;
} vibeos_blk_driver_t;

One entry point rather than three. read, read_many and write today are the same operation with different shapes, which is why write has no write_many — nobody noticed the asymmetry because nothing writes.

Registration, not weak symbols. The PE/COFF lesson is written down in CLAUDE.md: a weak definition in a different object from its caller links on Linux and fails on Windows. Every extension point here is a table.

A driver never blocks forever. Every wait is bounded and the bound is a counter, because virtio-net taught this project that a lock around something that already waited turns one stuck transmit into every core parked behind it.

B1 — block devices

The request is a value, not a set of arguments:

typedef struct vibeos_blk_request {
    uint32_t device;
    uint64_t lba;
    uint32_t sectors;
    void    *buf;
    int      write;
    vibeos_blk_result_t result;   /* filled by the driver */
} vibeos_blk_request_t;

Two things this buys, and both are answers to defects this project has had.

A reason, not a zero. vibeos_blk_result_t distinguishes not issued, device absent, timed out, medium error, short transfer and ok. Today a caller cannot tell a disk that is missing from a disk that is broken, and the FAT chain reader once returned an end-of-chain marker for a failed table read — the same value a healthy last cluster returns.

Multiple devices. There is one global device today. A machine with a disk and a CD, or a disk and a swap partition on another spindle, cannot be described. device is an index into a small table.

B2 — the block cache

One cache. Every sector read by any filesystem passes through it, including FAT. That is the single largest structural change in this plan and it is early, because "two structures that hold the same fact" is the shape of four separate defects in this codebase — most recently a page cache that returned the frames of a different file because two cores placed entries at once.

It has its own lock, supplied by registration, for the reason CLAUDE.md gives after the fourth time: a layer with mutable statics and more than one possible caller locks itself.

B3 — volumes

Three things that are usually confused with each other, and this plan keeps them apart because each fails differently.

Reading a partition table

MBR and GPT. Exists as kernel/fs/partition.c, host-tested, never run. A partition is a device to everything above: {device, first_sector, sectors}, which is the same shape B1 already takes and the same shape mm's vibeos_swap_area_t already takes. That is not a coincidence worth losing — a swap partition is a volume, and B3 is what fills that descriptor in.

GPT before MBR, and both. A machine that only understands MBR cannot read a disk any modern tool produces, and one that only understands GPT cannot read the boot media this project ships. The protective MBR on a GPT disk is exactly the trap: it parses as a valid MBR describing one huge partition, so a reader that tries MBR first and stops on success gets a plausible wrong answer rather than an error.

Probing what is on a volume

Given a volume, which filesystem is it? Each vibeos_fs_ops_t gains a probe that reads its own superblock and says yes or no, and the volume layer asks each in turn.

Probe order is a correctness question, not a preference. exFAT's boot sector is a FAT boot sector with different fields; a FAT probe that only checks the jump instruction and the signature says yes to an exFAT volume and then mounts it wrong. Each probe must identify its own filesystem positively and the order must be recorded with the reason.

Mounting more than one thing

There is one mount today, g_rootfs, a global. That is why the plan can say "only FAT runs": there is nowhere to put a second filesystem even if it worked.

A mount table with paths — a small fixed array, since this kernel does not allocate on these paths — and a lookup that resolves a path to (mount, tail). Everything above stops taking a vibeos_fsmount_t * and starts taking a path.

Writing a partition table

Creating, deleting and formatting. This is the only part of B3 that can destroy data that was not this machine's to lose, and it is treated accordingly: every write to a table is refused unless the caller passes the current table's checksum, so a stale view cannot overwrite a newer one. See the phase.

B4 — files

vibeos_fs_ops_t, unchanged in shape. What changes is that every filesystem reaches the disk the same way, and that the read contract stops conflating two numbers:

/* Bytes delivered into buf. Never more than len. */
long vibeos_fs_read_at(mnt, node, offset, buf, len);

/* How long the file is. A different question, and asking it must not
 * require a buffer that could hold the answer. */
int  vibeos_fs_size(mnt, node, uint64_t *out_size);

That separation is what unblocks the memory manager: execve can then read a header window while knowing the file's real length, and the 6 MiB of staging buffers become a header window each.

Where the journal is not

It is not one of the five layers, and putting it in kernel/fs/ — where it is today — says it is a filesystem concern. It is not.

A journal answers one question: do these writes happen together or not at all? That is a transaction, and transactions are orthogonal to both files and blocks. ext2 wants one; FAT does not have one; and the partition-table writer in I4c wants exactly the same thing when it puts the backup table down before the primary. Two customers in two different layers is the test of whether something belongs in a module of its own, and this passes it.

Its failure mode is different from everything else here too. The rest of this stack fails by returning the wrong data, which a test can see immediately. A journal fails by not being replayed, which is invisible until the power goes out — so its verification is "interrupt the write at every point and check what comes back", a different kind of harness from anything else in this plan.

So: kernel/txn/journal.c, above B2, used by whoever needs it.

The one thing that cannot wait

A journal controls the order in which writes reach the medium, and a cache that reorders around a barrier makes it worthless. That contract is between the journal and B2, and it has to exist before write-back is designed — not after.

/* Everything submitted before this reaches the medium before anything
 * submitted after it. Not a flush: a flush empties the cache, a barrier
 * only orders it, and a journal that used a flush would write the whole
 * cache out on every transaction. */
int vibeos_blk_barrier(uint32_t device);

If I4 lands a write-back policy with no barrier in it, adding the journal later means rewriting the write path a second time. That is the whole reason this section is here rather than in the phase that builds the journal.

Where the mm page cache fits

It stays where it is, above B4, keyed by file identity rather than by sector. Decision D6 of the mm plan already settled that the two caches are not merged, and this plan does not reopen it — but it does make the reason cleaner: B2 caches sectors of a device, the mm cache holds pages of a file, and after this refactor the second sits on top of the first instead of beside it.

Observability

The storage path currently carries zero counters. Not few — none. The memory manager carries about twenty, most of them asserted by the boot gate, and the difference is why "the disk is slow", "the disk is retrying" and "the disk is fine" are the same silence today.

This document lists what each layer counts, including layers that do not exist yet. That is deliberate and it is what the memory-manager plan did: filling in a field later is cheap, migrating a structure and every assertion that reads it is not.

The rule this project arrived at the hard way

A number that is written and never read is not observability. Three subsystems have shipped one: the scheduler computed a time slice nothing consulted, the memory watermarks were set at boot and never checked from the allocation path, and reclaim grew a tier nothing called. Each was correct in isolation and wired to nothing.

So every counter below is in one of two states, and the state is written next to it:

  • asserted — the boot gate fails on it, and a sabotage case proves the assertion can go red;
  • diagnostic — printed for a human, and explicitly not a gate.

A counter in neither state should not be added.

B0/B1 — devices

counterstatewhy
reads, writes, sectors_read, sectors_writtenasserted non-zeroa boot that reads nothing has not booted
errors[reason]asserted zero for medium/shorta disk returning errors is not a healthy boot
errors[absent]diagnostica machine with no disk is a configuration
timeoutsasserted zerothe bound exists so this can be zero; virtio-net's transmit bound is the precedent
max_wait_iterationsdiagnostichow close the bound came to firing, which is the number that says a bound is too tight before it fires
requests_in_flight_peakdiagnosticsized the queue when I6 lands

max_wait_iterations is the one worth arguing for. A timeout counter tells you the bound fired; it cannot tell you the bound is one retry away from firing on a slower machine. This project has already tuned a bound twice by watching failures instead of margins — virtio-net went 50M, 2M (which broke the network), then 20M.

B2 — the block cache

counterstatewhy
hits, missesasserted as a ratiothe mm page cache shipped at 36% while "non-zero" passed happily; a floor near what the thing actually does is the only useful kind
evictions, residentdiagnosticsizing
wrong_key_returnedasserted zerothe page cache once returned another file's frames; this is that defect's counter
write_back_pending, write_back_failedasserted zero for faileda write-back that fails silently is a lost file

B3 — volumes

counterstatewhy
volumes_found, mountsasserted non-zeroa boot with no mounted volume has not booted
probe_rejected[fs]diagnosticwhich filesystem said no to what, which is the only way to debug a disk that mounts as the wrong type
table_writes_refused[reason]asserted zero unless a test caused ita refused table write is either a bug or a test

B4 — files

counterstatewhy
opens, reads, bytes_readasserted non-zero
short_readsasserted zerothe defect this project already had: a short read reported as a complete file, and execve parsing the previous program's bytes
writes, bytes_writtenasserted non-zero once I4 landsuntil then, evidence that nothing writes

Events, not just counters

Counters say how often. Some things need to say which, and this project has learned that the hard way twice — a fault that named an address but not the program, and a free that named a frame but not the caller.

  • [BLK] error — device, LBA, sector count, direction, reason. One line, written under the console lock in a single call, because a diagnostic split across several calls comes back interleaved from different cores and reads as a contradiction.
  • [VOL] found — device, first sector, length, table type, probed filesystem. Printed for every volume at boot, so "the machine mounted the wrong thing" is answerable from the log rather than by adding printfs.
  • [FS] mounted — path, type, volume.

What the boot must be able to say without any new code

Three questions, and if a future defect makes any of them unanswerable from an ordinary boot log, that is a regression:

  1. Which devices exist, and how big are they?
  2. Which volumes are on them, and what is on each volume?
  3. What did the machine read and write, and did anything fail?

Maintainability — the rules, and what enforces them

A rule nothing checks is a preference. This project had the counterpart in scripts/dev/check-exec-layering.sh, which enforced that exactly one function may name an interpreter path — it existed because that rule was broken twice by people who agreed with it, and was deleted with the substitution in docs/abi/ A4.

The structural rules

R1 — one place reaches the disk. No file outside kernel/io/ calls a driver. Today fat.c calls vibeos_x86_64_blk_read directly, which is how the only filesystem that runs came to bypass the only cache there is.

Enforced by check-io-layering.sh: grep for driver entry points outside kernel/io/, fail on any hit.

R2 — one cache below the file layer. A filesystem does not keep its own sector buffer. fat.c's two static buffers are the current violation.

Enforced by the same script: a static array of sector size in a filesystem source is a finding, listed with an allow-list that must be empty by the end of I2.

R3 — a filesystem knows nothing about partitions, and a volume knows nothing about filesystems. A filesystem is handed a volume and reads from offset zero of it. The volume layer never parses a boot sector; it asks each filesystem's probe.

Enforced by review and by the shape of the ops table — a probe that takes a volume rather than a device cannot look outside it.

R4 — every error carries a reason. No function in this subsystem returns a bare non-zero for a failure that has more than one cause.

Enforced by the sabotage cases: each phase has one that reports the wrong reason, and it must turn a test red.

R5 — every extension point is a table, never a weak symbol. The PE/COFF lesson: a weak definition in a different object from its caller resolves on Linux and fails on Windows, and the Linux build says nothing.

Enforced by the Windows CI job, which already exists.

R6 — every wait is bounded and the bound is counted. Including max_wait_iterations, so a bound that is one retry from firing is visible before it fires rather than after.

How each phase is verified

The same ladder the memory-manager phases used, and in this order:

  1. Host tests, against a fake device. A layer that cannot be host-tested has its boundary in the wrong place — that is the test of a boundary, not a consequence of one.
  2. A sabotage case file under scripts/dev/cases/. Every case applied, the tests confirmed red, and the tree confirmed green again afterwards. A sabotage run that starts on a tree which does not pass proves nothing: eight cases once "passed" here having executed nothing, because the verify script lived in /tmp and WSL had cleaned it.
  3. A boot-gate assertion, where the phase reaches the running machine.
  4. repeat-boot.sh, at 24 for I2 and at 8 elsewhere. One clean boot proves nothing; this machine has a background failure rate of roughly one boot in eight, and a criterion has to be checked against the baseline before it is used to judge a change.

What a test must not be

Three failure modes, all of which have happened here, and all of which look like a passing test:

A test that asserts a condition it never reaches. Two NTFS cases once passed vacuously; a swap-map pool was sized by guessing the node at 32 bytes when it is 24, so the exhaustion case never exhausted anything.

A test that is right about the outcome and wrong about the mechanism. Three times in the memory-manager work a sabotage walked through a green test, because the test asserted the correct property about an arrangement in which the defect could not show. Only breaking the code on purpose separates them.

A test arranged differently from the code it exercises. The compaction tests held an allocation reference the kernel hands over, so every move was correctly refused and the tests were wrong rather than the layer.

Definition of done, per phase

  • Host tests pass, and each has been seen to fail.
  • The sabotage file exists, every case has been applied and confirmed red, and any case that cannot be turned red here is recorded as unverifiable rather than counted — the distinction the AHCI cases already carry.
  • check.sh all green on gcc and clang. gcc accepts an implicit declaration that clang rejects; that has broken CI here before.
  • The docs updated: a row in docs/implementation_progress.md, a detail file, and make-book-summary.py re-run so it reaches SUMMARY.md.
  • What the phase did not do is written down by name, not implied.

Phases

Each phase says what it builds, what proves it, and what turns it red. The order is not negotiable in one place and says so.

Every phase ends the same way: host tests, a sabotage case file under scripts/dev/cases/, and — where the phase reaches the running machine — a boot-gate assertion. A phase with no sabotage case is not finished, because this project has twice shipped a check that could not fail.


I0 — the contract, with nothing behind it

Objective. The types exist and nothing implements them.

Files. include/vibeos/blkdev.h, include/vibeos/io_stats.h, tests/kernel/blkdev_tests.c

Steps.

  1. vibeos_blk_request_t, vibeos_blk_result_t, vibeos_blk_driver_t.
  2. The statistics struct, complete — including counters for layers that do not exist yet. The memory manager did this and it was right: filling in a field is cheaper than migrating a structure and its assertions a second time.
  3. A registry of devices: bind, look up, enumerate.

Done when. A fake driver can be registered, a request submitted to it and the result read back, entirely in a host test.

Sabotage. io-contract.txt — accept a request with no buffer; report a short transfer as ok; let two drivers claim the same device index.


I1 — the block layer

Objective. One entry point, real errors, more than one device.

Files. kernel/io/blkdev.c, and the two drivers move to it.

Steps.

  1. vibeos_blk_submit(request), synchronous for now.
  2. virtio-blk and AHCI register tables instead of being bound through three function pointers.
  3. Every wait bounded, every bound a counter.
  4. read and write become symmetric — the missing write_many is not an omission to fix later, it is evidence that nothing writes.

Done when. The boot mounts and runs exactly as before, through the new layer, with blk_reads, blk_writes, blk_sectors and blk_errors_by_reason non-zero and asserted.

Sabotage. io-blkdev.txt — lose the sector count on a multi-sector transfer; report a timeout as success; ignore the device index.


I2 — one cache, not three

Objective. Every sector any filesystem reads goes through one cache.

This is the risky phase and it is deliberately early, because the risk is not in the change — it is in living with three caches for another six months.

Steps.

  1. The block cache gets its own lock by registration and a full set of counters: hits, misses, evictions, and a ratio the gate asserts rather than a presence check. The mm page cache shipped at 36% while "non-zero" passed happily.
  2. FAT stops reading sectors directly. fat.c's static sector buffers go.
  3. kernel/fs/blockcache.c becomes the only cache below B4.

Done when. cache_hits and cache_misses are both non-zero on a boot, the ratio is asserted, and FAT's static buffers are gone from the source.

Sabotage. io-cache.txt — return the wrong sector for a key; never record a hit; skip eviction; drop the lock.

Risk, stated plainly. FAT is how the machine boots. A defect here does not degrade anything, it stops the machine — and the page cache's history says the failure mode is a hit that returns the wrong data, which looks like a working boot until a program is handed somebody else's bytes. The verification for this phase is repeat-boot.sh at 24, not a single green boot.


I3 — the read contract: a length is not a byte count

Objective. Asking how long a file is stops requiring a buffer that could hold it.

This is what the memory manager is waiting for.

Steps.

  1. vibeos_fs_size() beside vibeos_fs_read_at().
  2. hw_read_file_cached returns bytes delivered; the file's length is asked for separately.
  3. execve reads a header window. Both staging buffers shrink from 4 MiB and 2 MiB to that window, with a named refusal — not a truncation — for a file whose headers exceed it.

Done when. meminfo no longer shows the staging buffers, and mm's P4 step 3 is closed.

Sabotage. io-length.txt — return the staged count as the length; accept a file whose headers exceed the window; report a short read as a complete file.

The last one is not hypothetical. It is exactly the FAT defect this project already had: fat_next_cluster reported a failed table read as end-of-chain, the reader returned the size the directory claimed, and execve parsed the previous program's bytes. The case exists to make sure the new contract cannot express that.


I4 — writes that are proved

Objective. Something writes, on a real machine, and the bytes survive.

Steps.

  1. write_many, and the write path through the cache with a write-back policy that is stated rather than implied. 1b. A barrier, vibeos_blk_barrier(device): everything submitted before it reaches the medium before anything submitted after. Not a flush — a flush empties the cache, a barrier only orders it.

    This is in I4 rather than in the journal phase deliberately. The journal is a later module and could wait; the ordering contract it needs cannot, because a write-back policy designed without it has to be rewritten when the journal arrives. The partition-table writer in I4c needs the same primitive for the same reason, which is the evidence it belongs at this level rather than inside whoever asked first.

  2. A boot-time exercise: write a file, read it back, compare byte for byte.

  3. The same across a reboot, which is the only test that distinguishes a write that reached the medium from one that reached a cache.

Done when. The gate asserts the round trip and the across-reboot check.

Sabotage. io-write.txt — acknowledge a write that was never issued; write to the wrong LBA; lose the last sector of a multi-sector write; report a write-back failure as success.

Why this is high on the list. Writes already reach the medium - a boot does thirty sector writes, through the shell's mkdir - and nothing checks them. That is worse than not writing at all: the machine is modifying a disk with no evidence that what it wrote is what comes back, and a defect there is silent until the next boot cannot mount.

The across-reboot check is the one that matters most, because it is the only one that distinguishes a write that reached the medium from one that reached a cache and was never flushed.


I4b — volumes: partitions, probing, and more than one mount

Objective. A disk is more than one filesystem, and the kernel can say what is on it.

Files. kernel/io/volume.c, kernel/io/parttab.c, and kernel/fs/vfs.c grows a mount table.

Steps, in order.

  1. Read both table formats. GPT and MBR, with GPT tried first and the protective MBR recognised for what it is. A GPT disk parses as a valid MBR describing one huge partition, so a reader that tries MBR first and stops on success gets a plausible wrong answer rather than an error — which is the worst kind, because nothing reports it.
  2. A volume is a device. {device, first_sector, sectors}, the same shape B1 takes and the same shape mm's swap area takes. Everything above B3 stops knowing whether it is looking at a whole disk or a slice of one.
  3. Probe. Each vibeos_fs_ops_t gains a probe that reads its own superblock. The order is recorded with its reason: exFAT's boot sector is a FAT boot sector with different fields, so a FAT probe that checks only the jump and the signature claims an exFAT volume and mounts it wrong.
  4. A mount table. There is one global mount today, which is the structural reason only one filesystem can run. A small fixed array, no allocation on these paths, and a resolver that turns a path into (mount, tail).

Done when. The boot lists the volumes it found, with their type and size, and mounts more than one.

Sabotage. io-volume.txt — accept the protective MBR as a real table; report a partition that extends past the end of the disk; let two mounts claim the same path; have a probe say yes to a filesystem that is not its own.


I4c — writing a partition table

Objective. Create, delete and format a volume — and never the wrong one.

Separated from I4b deliberately. Reading a table wrongly gives a machine that cannot boot, which is loud. Writing one wrongly destroys data that was never this machine's to lose, and it does it silently and immediately. They do not belong in the same change.

Steps.

  1. Every table write takes the checksum of the table it is replacing, and is refused if the disk no longer holds it. A stale view must not be able to overwrite a newer one.
  2. A refusal to touch a partition that is mounted, or that the swap area names.
  3. Format: put a filesystem on a volume, and only through the filesystem's own format op — the volume layer must not know what a FAT boot sector looks like.
  4. GPT's backup table at the end of the disk written before the primary, so an interrupted write leaves a disk with a recoverable table rather than two corrupt ones.

Done when. A test partitions a scratch device, formats it, mounts it, writes a file, unmounts, re-reads the table and finds the same layout.

Sabotage. io-parttab.txt — write the primary before the backup; accept a stale checksum; format a mounted volume; write a table whose entries overlap.

Confirm before doing. This is the one phase in this plan that can lose a user's data, and the case file should say so at the top rather than at the bottom.


I5 — the filesystems that have never run

Objective. ext2, NTFS, ISO9660 and exFAT are mounted from real images and read from, in CI.

Steps, one filesystem at a time. Each is its own change: build a small image with the host's own tools (mke2fs, mkisofs, mkfs.exfat), attach it as a second device, mount it, read a known file, compare bytes.

Done when. Each has a gate assertion naming it, and the boot says which filesystems mounted.

Why one at a time. Four filesystems landing together means a failure is attributed by guesswork. This project's own rule about counts applies to changes as well: change one thing.

And a decision this phase forces. A filesystem that cannot be given a real image in CI should be deleted, not kept. Two thousand lines that nothing runs is not a feature, it is a liability with a plausible name — and this plan would rather record that honestly than carry it.


I5b — the kernel's own log, on disk

Objective. A machine that crashes leaves its log behind.

Not the same thing as I5c. journal.c is a write-ahead journal for filesystem consistency. This is the operating system recording what happened, so it can be read after the machine that recorded it has stopped. The two share a word and nothing else, which has already caused one misunderstanding.

Why it matters more here than it would elsewhere. Every hard defect in this project was diagnosed from a serial log — the silent wedge, the interleaved console, the copy-on-write corruption, all of it. On a developer's machine QEMU captures that. On an appliance, or on real hardware with no serial cable, a wedge leaves nothing at all, and the only evidence of the last several failures would not have existed.

Steps.

  1. A reserved area, not a file. It has to be writable when the filesystem is broken — which is exactly when it is most wanted — so it is a volume from B3 with a fixed header, addressed by sector.
  2. A circular buffer with a monotonic sequence number, so the reader can order records across a wrap and across reboots, and can tell "the machine stopped" from "the log wrapped".
  3. Write-through, and never write-back. The last few lines before a crash are the entire point, and a cache holding them when the power goes is the one failure this feature cannot have. This is the same ordering contract the barrier in I4 provides, used for the opposite reason: the journal wants ordering to be able to batch, this wants it to refuse to.
  4. It must not allocate, and it must work from a panic handler — which means from a context where another core may hold the console lock and where the scheduler is parked.
  5. A reader: log on the kernel console shows the previous boot's tail.

Done when. A boot writes records, the machine is reset, and the next boot prints what the previous one recorded — including the last line before the reset.

Sabotage. io-logsink.txt — lose the last record before a reset; wrap without advancing the sequence; allocate on the write path; write through the cache instead of past it; let a panic-time write take a lock it might not get.

The one that matters most is the first. A log that loses its last line loses the only line anybody wanted.

Deliberately after I4b, because it needs a volume to live in, and deliberately before I6, because asynchrony is precisely when a machine most needs a log that is already on the medium.


I5c — the journal, as a module of its own

Objective. "These writes happen together or not at all", available to anything that needs it.

Files. kernel/txn/journal.c — moved out of kernel/fs/, which is where it is today and which says it is a filesystem concern. It is not: ext2 wants one, FAT has none, and I4c's partition-table writer wants exactly the same guarantee when it puts the backup table down before the primary. Two customers in two layers is what makes it a module rather than a feature.

Steps.

  1. Move it, unchanged, and give it the barrier from I4 instead of whatever ordering it assumes today.
  2. A transaction API that is not filesystem-shaped: begin, add a write, commit, and a replay run at mount.
  3. ext2 uses it. Then I4c uses it, which is the change that proves it is not an ext2 feature wearing a general name.

Done when. A transaction interrupted at any point leaves the medium either fully updated or fully unchanged, demonstrated rather than argued.

Sabotage. io-journal.txt — commit before the data is on the medium; replay a transaction that never committed; skip replay at mount; reorder across a barrier.

The verification is the interesting part. Every other phase here is checked by a test that reads something back. This one needs the write interrupted at each step, which means a driver that can be told "fail after N sectors" and a mount that then has to recover. That harness is worth building once and is reusable for I7's failing-disk property — which is why I5c comes before I7 and not after it.

And what the row in the progress table currently claims. It says "journalled writes verified against power loss", which is true of a host test and not of any machine that has ever run this kernel. This phase is what would make the sentence mean what it appears to mean.


I6 — asynchronous I/O

Objective. A request can be submitted and completed later.

Deliberately last, and it cannot move earlier. Asynchrony makes every existing defect harder to see: a wrong sector returned synchronously is a bug you can print, and the same bug behind a completion queue is a bug you reproduce one boot in thirty. Everything above must be correct and counted first.

Steps. A completion callback; a queue per device; interrupt-driven completion for virtio-blk and AHCI; the synchronous entry point kept as a wrapper that waits, so nothing above has to change at once.

Sabotage. io-async.txt — complete a request twice; complete the wrong request; lose a completion; run the callback with the queue lock held.


I7 — acceptance

Each is a test that does not exist.

  • A disk that fails. A driver that returns errors on demand, swept across which request fails. Every failure must leave no request in flight, no sector wrongly cached, and a reason the caller can print.
  • A disk that is absent. The machine boots, says so, and refuses cleanly.
  • A disk that is slow. Every bound is asserted and every timeout counter is zero on a healthy boot.
  • Isolation. A sector freed from the cache and re-read is never served from the previous tenant's data.
  • Soak. Sustained read and write, with the cache's frame count returning to where it started — the same property the memory manager now asserts for userland.

Done when. All five run in CI, each has a sabotage case that turns it red, and every claim in README.md §2 maps to the test that proves it.

Decisions, risks, and what is reserved to you

The risks, in the order they would hurt

D-R1 — I2 can stop the machine. FAT is how this kernel boots, and moving it onto the shared cache touches every sector read during boot. The failure mode that matters is not a crash: it is a hit that returns the wrong sector, which looks like a working boot until a program is handed somebody else's bytes. The page cache did exactly that, and it took four attempts to find, three of which produced confident wrong answers.

Mitigation: 24 boots, not one. An audit that compares a cached sector against a fresh read, on by default during the phase. And the phase lands alone.

D-R2 — I4c can destroy data that is not this machine's. Writing a partition table on a machine somebody uses is the only operation in this plan whose worst case is somebody else's files. It is separated from reading tables for that reason, and its case file should carry the warning at the top.

D-R3 — I5 may delete more than it fixes. Four filesystems have never run. Some may be far from working, and the honest outcome for one of them may be deletion. That is a decision with a cost — somebody wrote them — and it is yours, not the plan's.

D-R4 — the refactor touches the boot path throughout. Unlike the memory manager, which could be built beside the old code and switched on, storage is load-bearing from the first sector. Each phase must leave the machine bootable.

Decisions already taken, with the reason

D1 — one entry point per driver, not three. read, read_many and write are one operation with three shapes, and the asymmetry (no write_many) is evidence that nobody was looking, because nothing writes.

D2 — the request is a value. So a result can carry a reason and so I6 can queue it later without changing every caller.

D3 — GPT is tried before MBR. A protective MBR parses as a valid table describing one huge partition. Trying MBR first and stopping on success gives a plausible wrong answer, which is worse than an error.

D4 — the block cache is not merged with the mm page cache. Settled as D6 of the memory-manager plan and not reopened. B2 caches sectors of a device, the mm cache holds pages of a file; after this refactor the second sits on top of the first rather than beside it.

D5 — asynchrony is last. Queueing makes every existing defect harder to see: a wrong sector returned synchronously is a bug you can print; the same bug behind a completion queue is one you reproduce one boot in thirty. This is the tempting order to invert and the wrong one.

D5b — the journal becomes its own module, and later; its barrier does not. A journal is a transaction concern, orthogonal to files and to blocks, with two customers in two different layers — ext2 and the partition-table writer. That makes it a module rather than a filesystem feature, and it can wait.

What cannot wait is the ordering primitive it depends on. A write-back policy designed without a barrier has to be rewritten when the journal arrives, so vibeos_blk_barrier lands in I4 with the writes. A barrier, not a flush: a flush empties the cache and would write everything out on every transaction.

D6 — no new filesystem features. ext2 gaining symlinks is building on ground nobody has walked on.

Reserved to you

These are not the plan's to decide.

Q1 — how far does write support go? Three defensible answers, and they are different amounts of work:

  • read-only everywhere except FAT — enough for a machine that boots and logs;
  • read-write on FAT and ext2 — enough to be a system somebody uses;
  • read-write everywhere — a year of work on filesystems nobody has run yet.

The plan assumes the second and says so, but I4 is written so the first is a stopping point rather than a failure.

Q2 — what happens to the filesystems that have never run? Mount them in CI, or delete them. Two thousand lines that nothing runs is not a feature; it is a liability with a plausible name. But deleting somebody's work is a call I will not make on my own.

Q3 — does a swap partition come from this layer now? mm's swap area descriptor is already the shape B3 produces. Wiring them means swap runs on the real machine for the first time — which is worth a lot — and it means the memory manager depends on a layer being refactored underneath it. Doing it in I4b is cheap; doing it later is safer.

Q4 — how many devices, and how many mounts? Fixed arrays, since this kernel does not allocate on these paths. Four devices and eight mounts is a guess. Whatever the number, *_peak counters say afterwards what it should have been, the way rmap_peak and nodes_peak already do.

Q4b — how much disk does the log get, and does it survive a reformat? A reserved area outside any filesystem survives anything short of repartitioning and is readable when the filesystem is broken. A file is easier to get at from a host but is gone the moment the volume is. The plan assumes the reserved area and says so; the size is a guess until records_dropped says otherwise.

Q5 — is a partition editor a kernel feature at all? I4c could be a userspace tool talking to a narrow ioctl instead. That is smaller, safer, and arguably where it belongs — and it is a design decision rather than an implementation one.

What I would do first, if you want a recommendation

I0, I1 and I3, in that order, before anything else.

I3 is the one that pays immediately: it unblocks the memory manager's last open step and deletes 6 MiB of kernel memory, and it is a contract change rather than a rewrite. I0 and I1 are what make it safe to do.

I2 is the largest structural win and the largest risk, and it should land alone, on a quiet day, with 24 boots behind it.

The kernel core

Four requirements drive this plan, and they are not four separate plans: maintainable, modular, observable, fast, with the size of arch_hw.c as a symptom of the first two rather than a goal of its own.

Read them in this order:

  • architecture.md — the shape, and how each of the four is measured.
  • phases.md — what each phase builds, what proves it, what turns it red.
  • invariants.md — the properties that must survive every phase, and the counter or assertion that watches each one.

The state of things, measured rather than remembered

kernel/arch/x86_64/arch_hw.c is 10,087 lines in 19 sections. The five largest are the socket syscalls (1,654), the task table and scheduler entry (1,363), the C-runtime syscalls (1,185), the kernel log (724) and the page pool (685). The Linux ABI, counted across its four sections, is 4,169 lines — 41% of the file.

The portable half is about 19,000 lines across thirteen directories, and it is no longer uniformly dead. That claim, which the first version of this plan was built on, is now wrong in an important place: hw_pick_next calls vibeos_sched_policy_pick and vibeos_runq_pick. The scheduler phase this plan was going to do has already happened. What is still unreached is kernel/core/syscall.c and kernel/proc/process.c — 2,592 lines of second dispatcher that the arch layer names zero times.

The three things that are actually wrong

1. Adding to the monolith is the cheapest thing to do. Twice, a session whose stated purpose was shrinking that file grew it — by 633 lines once, by 2,800 the next time — while the document saying its length was the completion criterion sat unchanged in the repository. A file grows until adding to it stops being easiest. That is why this plan's success criterion is not a line count. It is blast radius: how many files you must edit to add one thing.

It is 21 across six extension points today, measured rather than asserted — and the first measurement contradicted this plan, which had claimed two of those points already cost one file. Nothing costs one file. The one driver that uses a registry costs the same as the four that are hardcoded, because it still needs a bring-up call. See architecture.md.

2. Almost nothing reports whether it is healthy. The kernel prints 25 distinct report tags and has five must-be-zero counters in the entire tree. Every hard defect in this project's history was found by a counter or a detector, and most subsystems have neither.

3. Nothing here has ever been measured for speed. Not once. The seams this plan adds are indirect calls on paths that run millions of times a boot, and there is no number to compare against — so any claim about their cost, in either direction, would be invented.

The trap, restated because it is the likeliest way to fail

kernel/core/syscall.c and kernel/proc/process.c look like the portable kernel this plan wants. They are what a portable kernel looks like when nobody runs it, which is the state this plan exists to end, and they carry three uncorrected defects: thread creation with no check on the caller, a process slot that is never freed, and a caller identity read from an argument the caller supplies.

Nothing there may become reachable from ring 3 before those are fixed. Not afterwards. Not in the same change.

The shape of the core

The first version of this document said where code should live. That is a tidying plan, not an architecture, and it would have produced a smaller arch_hw.c extended the same way — by editing it.

This version is organised around the four requirements, and each is stated with the number that decides whether it has been met. A requirement without a measurement is a preference, and this project has a rule about those.


1. Maintainable

The definition, because the word on its own means nothing

CLAUDE.md, read as a whole, is a catalogue of maintenance failures. Not one of them was code that was hard to read. Every single one was a change that was wrong for months with nothing saying so:

  • a field written on one path and read on all of them (interp_base);
  • a struct built member by member that later gained a member (read_many);
  • a layer serialised by the accident of having one caller (backing.c);
  • a counter defined, printed, asserted, and produced by nothing (VIBEOS_BLK_TIMEOUT);
  • a check that had been red since before anybody arrived (check-mm-layering.sh);
  • a mechanism configured and consulted by nobody, three times in one session.

So maintainability here is the number of ways a change can be silently wrong, and it decomposes into three quantities that can each be measured:

PropertyQuestionMeasured by
Blast radiusHow many files must I edit to add one thing of a known kind?check-blast-radius.py
Time to redHow long between making that mistake and something saying so?wall clock on check.sh all, and whether a sabotage case exists at all
LocalityIs there exactly one place that decides this?check-reachable.py, check-chokepoints.py

Blast radius is the one that matters most

This section used to open by saying blast radius was "already excellent in one place and terrible everywhere else", and gave this table:

| a filesystem | 1 — vibeos_storage_register | | a block driver | 1 — vibeos_blk_register | | everything else | arch_hw.c |

That was wrong, and it was wrong in the document that calls this the plan's real progress metric. It was written from the existence of two registries, not from a measurement. check-blast-radius.py was then written to enforce it and contradicted it on its first run.

To add...Files edited todaywhich
a filesystem, the way four of the five arrived4storage.c, storage.h, io_bringup.c, the source list
a filesystem through the registry (FAT, the only one)4arch_hw.c, arch_x86_64.h, io_bringup.c, the source list
a block driver4arch_hw.c, arch_x86_64.h, kmain.c, the source list
a network interface4arch_hw.c, arch_x86_64.h, kmain.c, the source list
a display3arch_hw.c, serial.c, the source list
an input device2arch_hw.c, the source list

Three things follow, and none of them was visible before the number existed.

Nothing measures 1. The floor across the whole tree is 2.

The registry buys nothing yet. FAT is the one driver that registers, and it costs the same 4 as the four filesystems that do not: 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. That is the sentence C6 has to falsify, and it is a much sharper target than "move code out of arch_hw.c".

The four filesystems were never evidence. ext2, ntfs, exfat and iso9660 are named directly in g_probes[] in kernel/fs/storage.c, each with a mount wrapper there and a member in vibeos_volume_t. They did not arrive without their layer being touched; they arrived by editing it, four files at a time.

What survives from the old paragraph is the observation underneath it: there are eight registration seams in this tree (vibeos_frame_set_lock, vibeos_rmap_set_lock, vibeos_cache_set_lock, vibeos_blockcache_set_lock, vibeos_swapmap_set_lock, vibeos_task_view_set_source, and the two registries), each invented separately by somebody solving one problem, and never named as a rule.

A subsystem is extended by registering into it, never by editing it — and registering must not require an edit either, or the seam is decoration.

check-blast-radius.py turns that from advice into a number: for each extension point it names the files a new implementation touches, and the count may only go down. It is ratcheted at today's measurement — 21 across six points.

Time to red

A mistake that turns something red in ninety seconds costs ninety seconds. The same mistake caught by a reader six weeks later has, in this project's own history, cost between a day and a full phase. Three properties follow:

  • Every module has a sabotage case file. 48 exist; the modules that lack one are exactly the modules whose defects were found by reading.
  • A check that fires must be suspected before it is believed. Three detectors in one session reported defects that were their own bugs, and one reported 3,019 use-after-frees in a boot that had none.
  • A check red since before you arrived is a check nobody reads. A limit is raised as a decision, in the change that earns it.

Locality

The one-definition rule, and the property this refactor is most likely to break: moving handlers out of a single file is precisely the operation that turns one choke point into two, and the second one always forgets a case.


2. Modular and well-structured

What a module is — seven parts, none optional

Every subsystem here that works has the same seven. Nobody wrote them down, so each new one was assembled from memory and several arrived missing a piece — which is how a page cache with no lock ran for months on the accident of having exactly one caller.

  1. One header declaring the types and the entry points.
  2. One .c owning all of its mutable state. No other file names its globals — what check-mm-layering.sh already enforces for page tables, generalised.
  3. Its own lock, registered rather than assumed. Its own, because the page cache allocates frames and handing it the frame layer's lock would deadlock on the first miss. Registered, because a weak symbol does not resolve across objects under PE/COFF and the Windows job proved it.
  4. A statistics struct with at least one must-be-zero counter, so that "this is broken" is a number rather than a log line somebody has to read.
  5. An init that can fail and names the reason. A refusal naming a mechanism instead of a situation costs a session — pml4_absent_or_not_user held one open from end to end.
  6. A registration point, if anything can be plugged into it.
  7. A sabotage case file. Three checks here have shipped unable to fail.

check-subsystem.py enforces the mechanical parts. The parts a script cannot judge stay in review, and this document says which is which rather than pretending the script covers everything.

The layering test, stated as a question rather than a word

"Portable" is the wrong test and produced the current situation: a portable kernel that is portable and does not run.

Would a second architecture need a different answer, or the same answer implemented differently?

Same answer, different implementation → the arch layer provides a mechanism and the core decides. Picking the next task is the same answer everywhere; writing cr3 is not. Deciding a signal is permitted is the same answer everywhere; building the frame it is delivered on is not.

Different answer → the arch layer owns it entirely. There is no core opinion about what an IDT is.

This is deliberately not "no #ifdef in core". There is one architecture, and building for a second that does not exist is how the present portable kernel became untested and unreachable.

The module inventory

Every one of arch_hw.c's nineteen sections, measured, with a destination. This is the structure, and it is also the answer to the monster file.

SectionLinesDestinationWhy
socket syscalls1,654kernel/net + kernel/abi/linuxsame answer everywhere
task table + scheduler entry1,363kernel/proc + arch switchthe decision leaves, cr3 stays
C-runtime syscalls1,185kernel/abi/linuxit is an ABI by definition
the kernel log724kernel/diagsame answer; the UART is the mechanism
page pool + address spaces685split: policy out, page tables staydifferent answer per arch
APIC + SMP bring-up679arch/smpstays — different answer
Linux syscall layer679kernel/abi/linuxthe translator itself
what a program needs running651kernel/abi/linuxABI surface
page cache588kernel/ioalready has a home there
process creation510kernel/exec (116 lines today)ELF is not architecture
IDT396arch/trapstays
swap bridges316kernel/mmalready has a home
SMP locking146arch/cpustays
networking bring-up145arch/netstays
per-CPU state133arch/cpustays
crash records56kernel/diagsame answer everywhere
paging47arch/mmstays
SYSCALL/SYSRET37arch/syscallstays
GDT + TSS28arch/cpustays

Leaves: 7,726 lines. Stays: about 2,360. And the largest single block that leaves — 4,169 lines, 41% of the file — is the Linux ABI, which the next refactor on the roadmap has to move anyway.

Shrinking the monster file and building the multi-ABI syscall layer are the same work. Doing them separately means doing the larger one twice.

That is this document's central structural claim, and it is why the phase order changed.

The extension points the next year needs

The roadmap has three refactors after this one: drivers and peripherals, the network, and a syscall layer carrying Linux, Windows and macOS ABIs. An architecture that does not make those cheap is not worth the disruption of adopting.

Drivers. vibeos_blk_register is the model and it works. What is missing is that it is the only one. The core provides the registry and the contract; a driver provides a struct; nothing in the core is edited to add one. True for storage, false for everything else.

Syscall ABIs. An ABI is not a branch in the dispatcher. Three ABIs as three branches is three copies of every permission check, and the checks are where every security defect in this project has been found. An ABI is a registered translator:

vibeos_abi_t {
    name;                                  /* "linux", "win32", "macho"   */
    classify(frame) -> vibeos_syscall_id;  /* their number to ours        */
    marshal(id, frame) -> vibeos_args_t;   /* their calling convention    */
    result(id, value, frame);              /* our answer in their shape   */
}

The shape C4 starts from, lifted out of the code before it was deleted

kernel/core/syscall_policy.c held twenty lines that are exactly this idea, and it was deleted with the dispatcher that was its only caller. The idea is worth more than the file, so it is here instead:

vibeos_syscall_policy_t vibeos_syscall_policy_for(vibeos_syscall_id_t id) {
    switch (id) {
        case VIBEOS_SYSCALL_EVENT_SIGNAL:
        case VIBEOS_SYSCALL_WAITSET_ADD_EVENT:
            return (policy){ .requires_handle = 1,
                             .required_rights = HANDLE_RIGHT_SIGNAL };
        case VIBEOS_SYSCALL_HANDLE_CLOSE:
            return (policy){ .requires_handle = 1,
                             .required_rights = HANDLE_RIGHT_MANAGE };
        default:
            return (policy){ 0 };
    }
}

One function mapping a syscall id to the checks that apply to it, with the checks named rather than called - which is what lets a test assert that the declared set is the set actually run. C4 needs that whether or not the code it came from survives.

And the thing the live kernel does not have at all. Grepping the arch layer for vibeos_sec_* returns nothing: there is no capability model, no handle rights and no policy check on the path that actually serves ring 3. The design existed only in the half nothing reached. C4 is where it becomes real, and this is the note that stops it being reinvented from scratch.

The kernel keeps one vocabulary of syscalls, with the permission checks attached to that vocabulary and to nothing else. A process records the ABI it was created under; the dispatcher translates on entry and on exit. A second ABI adds a table, not a code path — and it cannot add a second copy of a check, because the checks live on the far side of the translation.

The Linux table that exists today becomes the first such translator. It is not rewritten; it is moved behind an interface it already has the shape of.

The GUI, and why it appears in a core document. It runs in the kernel and is the only subsystem the boot gate does not assert. It is also the current prime suspect for the argv corruption — white pixels turning up in another process's memory. No registered lock, no counters, no must-be-zero, no sabotage file, and a bug invisible for months. The contract above, in miniature.


3. Observable

What exists, counted

25 distinct report tags. Five must-be-zero counters in the whole kernel: frames_leaked, medium, double_free, bad_unlocks, illegal_transition. 48 sabotage case files. A 2,020-line boot gate. Two checks that watch the checks (check-assertions-covered.py, check-counters-produced.py).

The gap is not the tooling, which is unusually good for a project this size. The gap is coverage: most subsystems report what they did and nothing about whether it went wrong.

The three questions every module answers

  1. What did you do? Counters. Cheap, always on.
  2. What went wrong? At least one must-be-zero, asserted by the gate. This is the one most modules lack, and it is the one that has found every hard defect in this project.
  3. What are you now? A state dump on demand from the kernel CLI. crash and logdisk are the model.

check-subsystem.py enforces (2) — a module with no must-be-zero fails. That is a stronger rule than check-counters-produced.py, which only asks that a declared counter be written somewhere.

Four rules, each paid for in full

A counter nothing increments and a counter reporting nothing happening are indistinguishable. A new counter needs one deliberate demonstration that it can be non-zero before anything is concluded from a zero. A disk-interrupt counter read zero for an hour because the dispatcher hook had never been added, and sent four measurements in the wrong direction.

When a detector fires, the first question is whether the detector is right, and it is never answered by a count. A frame-poison check reported 3,019 use-after-frees in a boot with none. What settled it was making the detector say which frame, which word, what value, released by whom — it answered on the first boot. A counter says how often; only a witness says what.

A sampled zero is not evidence. The free-side mapping check looks at one free in sixteen, so a defect happening once a boot is very likely to be missed. A phase relying on those counters raises the sampling first and records what it raised it to.

Data nobody reads is not observability. The boot gate has recorded phase_history — per-phase timings — for its entire existence, and nothing has ever read it. That is the performance baseline, already collected, sitting unused. Section 4 reads it.


4. Fast

The honest starting point

This kernel has never been measured. No benchmark, no timing, no budget, no regression gate. Every statement anyone could make about its speed today, including a reassuring one, would be invented — and this project has a rule about numbers nobody measured.

That is what makes the performance requirement urgent rather than premature: the seams above add indirect calls to paths that run millions of times a boot, and without a baseline the refactor cannot be shown not to have cost anything.

The three hot paths

Syscall entry, page fault, context switch. Nothing else is close. An indirect call may not be added to any of the three without a measurement saying what it cost.

The rule that keeps the seams free

Registration is resolved once, not per call.

A driver is looked up when a device is opened, not on every request. An ABI is bound when a process is created, not on every syscall. A lock is registered at init, not chased through a pointer on every acquisition. Under that rule a seam is one predicted indirect call — a few cycles — and the danger is never the call itself. It is the lookup, or the lock, that somebody adds beside it.

The baseline, cheapest to build first

  1. Boot wall time, kernel_early_init to BOOT_OK. Already recorded in phase_history, already unread. Zero new kernel code.
  2. Syscalls per boot, and total cycles in the dispatcher. One counter and one rdtsc pair.
  3. A histogram per hot path — cycles, bucketed, min/median/p99. The one that catches a regression a mean would hide.

A [PERF] line carries all three, the gate ratchets it, and a regression past a stated margin turns the boot red. Like every other check here, it is confirmed by making a hot path slower on purpose and watching it go red.

What is deliberately not measured

Throughput of anything. There is no workload worth optimising for yet, and a benchmark chosen before there is a reason to care about it becomes the thing that gets optimised. Latency on the three paths is structural; everything else waits for a reason to exist.


What this architecture does not do

It does not introduce a module system, dynamic loading, or driver isolation. Those are real gaps and are on the roadmap. None of them can be built on a kernel whose subsystems have no stated shape, and attempting them first is how the shape gets decided by accident.

It does not make kernel/core/ portable to a second architecture. There is no second architecture, and building for one that does not exist is exactly how the present portable kernel became untested and unreachable.

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.

Phases

Read architecture.md first. These phases build the shape it describes; on their own they are a tidying plan, which is what the first version of this file was and why it was wrong.

Each phase says what it builds, what proves it, what turns it red, and which of the four requirements it serves — maintainable, modular, observable, fast. A phase that serves none of them does not belong here.

Every phase ends the same way: host tests, a sabotage case file under scripts/dev/cases/, and — where the phase reaches the running machine — a boot-gate assertion. A phase with no sabotage case is not finished, because this project has three times shipped a check that could not fail.

What changed in this revision, and why the order moved

C5 of the previous plan is already done. hw_pick_next calls vibeos_sched_policy_pick and falls back to vibeos_runq_pick. The scheduler decides and the arch layer switches; the policy is a pure function tested exhaustively; hw_task_runnable remains the single definition of runnable. It is recorded here rather than deleted, because a plan that quietly loses a finished phase is a plan nobody can audit.

The ABI phase and the file-shrinking phase merged. They were separate, and that was the plan's biggest mistake: 41% of arch_hw.c is the Linux ABI, and the next refactor on the roadmap moves it anyway. Doing them separately means moving four thousand lines twice.

Observability got its own phase, early. Five must-be-zero counters exist in the entire kernel. Every phase after it moves code, and moving code without detectors is how this project spends days attributing a failure to two things.


C0 — the machine is measurable before anything moves

Serves: fast, observable.

Two things, and the second has never been done at all.

The open defect

A large refactor on a machine with an intermittent memory bug makes every later failure attributable to two causes, and this project has already lost sessions to exactly that.

State, from scripts/dev/cases/mm-argv-poison.txt: an argv vector reads 0x00ffffff00ffffff — two white pixels, COL_TITLETXT from gui.c — and separately as the free-page poison. The same white-pixel value turned up independently as an out-of-range sector in the block layer. Every memory counter reads zero on those boots, which is the signature the code comment above the GUI back-buffer already describes: nothing is freed early, frames are simply handed out twice.

The open question is written in the case file: does hw_alloc_pages_contig reserve every frame in the run it returns, or return a run the frame table still believes is free? Answer it with an instrument, not a reading — three careful readings of the exit path missed the kernel-stack defect, and what named it was the freed_by tag.

The baseline

There is no performance measurement in this kernel of any kind. Build it in the order given, because each step is useful before the next exists:

  1. Boot wall time, kernel_early_init to BOOT_OK, from the gate's own phase_history — recorded since the gate existed and never read. No kernel change at all.
  2. Syscalls per boot and total dispatcher cycles. One counter, one rdtsc pair.
  3. A cycle histogram for each of the three hot paths — syscall entry, page fault, context switch. Bucketed, with min/median/p99, because a mean hides exactly the regression worth catching.

Done when. Sixteen consecutive boots with no POISON_BROKEN, no free_while_mapped and no bad-args refusal — the number the kernel-stack fix was held to. And a [PERF] line the gate ratchets, with the margin written down rather than implied.

Sabotage. mm-argv-poison.txt exists and carries the measurements; add the case that puts the defect back once it is found. core-perf.txt — slow a hot path on purpose and confirm the ratchet fires. Do that one first: a ratchet nobody has seen go red is a ratchet with no evidence behind it.

Done (2026-09-17). The open defect turned out to be two, both closed. The argv-poison/white-pixel corruption was SSE/XMM state never saved (mm_no_fpu_context.md); the intermittent THREADS crash was a task running with scheduler state READY after an in-place futex wake, then picked while it exited (boot_repeatability.md, "found and fixed"). rmap_mismatch was the audit bracketing only the owner count, not the holder side - completed, and a real one now arrives named. The baseline is core-perf.txt: the boot gate ratchets syscall_min (cycles, the machine-speed-independent number) against a characterised ceiling and boot_wall_s (kernel_early_init to kernel_boot) loosely; both were confirmed to fire before being trusted. A p99 histogram was deliberately not built - the design chose the minimum over the mean and the p99 for the reason argued at hw_perf_t, and the minimum is the ratchetable one.


C1 — the contract, and the checks that watch it

Serves: maintainable, modular.

Objective. The seven parts of a subsystem stop being folklore, and blast radius becomes a number.

Why before any code moves. Every later phase creates or reshapes a module. Writing the contract afterwards means writing down whatever was built — which is precisely how the present situation arose: eight registration seams, each invented separately, none named.

Steps.

  1. check-subsystem.py — done, for the four parts a script can judge. 50 modules: exported_state=0 (every module already confines its state — the one part of the contract this tree got right without being told), plus baselines no_header=15, no_case=35, state_without_lock=3.

    Two of the seven are deliberately left out and the file says so rather than implying coverage: the must-be-zero counter is C2's step 3, which adds the counters before the check that demands them — writing it now would ship a check red against thirty modules, which is the state check-mm-layering.sh sat in for a phase. An init that names its reason stays in review.

    The lock property was narrowed after its first run: asking "has a registered lock" called blkdev.c unlocked while it holds its own, 29 false positives. It asks "has mutable file-scope state and names no lock anywhere" now, which is 3 — vfs.c (the mount table), anon.c (a clock hand), forkguard.c (the fork budget). backing.c was that exact shape, and it was right for months on the accident of having one caller.

  2. check-blast-radius.py — done, and it corrected the step that asked for it. This said "storage is 1 and block is 1 today". Measured: 4, 4, 4, 4, 3, 2 across six points, total 21. Nothing is 1; the floor is 2; and the one filesystem that uses the registry costs the same as the four that are hardcoded into g_probes[]. The baseline is the measurement, and the target it implies is sharper than the one this plan started with: a seam a driver joins without anybody editing a bring-up path.

  3. check-chokepoints.py — each security check declares how many call sites it has. A second site is not forbidden, it is noticed: the failure guarded against is one choke point silently becoming two that drift.

  4. check-reachable.py — written, running, unreached=12 baseline=27. Wire it into check.sh all if it is not there yet.

Done when. All four run in check.sh all and each has been shown to go red by breaking the thing it watches. A checker whose red has never been seen is in the same category as VIBEOS_BLK_TIMEOUT.

Sabotage. core-contract.txt — a module with no lock; a counter no must-be-zero covers; a second call site for hw_signal_permitted; a function in kernel/core/ nobody names; a driver added by editing a registry.


C2 — every module says whether it is broken

Serves: observable.

Objective. The must-be-zero count goes from five to one per module, and the gate asserts all of them.

Why here rather than at the end. Every phase after this one moves code between files. The detectors are what make such a move provable rather than plausible, and this project's entire history says the same thing: the hard defects were found by counters and witnesses, never by review.

Steps.

  1. Enumerate the modules with no must-be-zero. Roughly: the GUI, the console, the keyboard and mouse, the network stack, exec, the ABI surface. The GUI is first, because it is the current prime suspect in C0's defect and has none of the seven parts.
  2. For each: name the harm, add the counter, print it on the module's tag, assert it in the gate, and demonstrate it can be non-zero before concluding anything from a zero.
  3. Extend check-subsystem.py to fail a module that has none. Ratcheted.
  4. Give the two heaviest detectors a witness as well as a count — which object, which word, what value, put there by whom. A count says how often; a count is what left found=0 freed_by=0 unexplained for a phase.

Done when. check-subsystem.py's must-be-zero baseline is zero violations, and each new counter has one recorded run in which it was non-zero on purpose.

Status (2026-09-19): done, with a stated remainder. check-subsystem.py's no_mustbezero is 0 of 51 modules. That is reached three ways, and the split is the honest part:

  • Real counters (16 modules). GUI, console, keyboard, IO, network (sock_stale_parent, sock_fd_aba), mouse (desync, proved non-zero at every boot), the ABI surface (unexpected_unimplemented + last_nr witness + a deliberate ring-3 probe), and - through the new registry kernel/core/mbz.c, which keeps a count and the last value that tripped it per harm - the ELF, ext2, exFAT, FAT-chain, ISO9660, NTFS, GPT and partition-table parsers, the journal's recovery, the log sink's torn records and the scheduler's failed requeue. The boot prints one [MBZ] MUSTBEZERO total= first= witness= line and the gate asserts it. test_mbz_all_demonstrated runs last in the host suite and fails if any id was never seen non-zero; on its first run it named three that the suite had never reached, and they got tests.
  • Asserted elsewhere (4). blockcache, swaparea, lifetime, backing already had a gated counter under another name; the ratchet greps the gate for that name, so deleting the assertion stops it counting.
  • Exempt by written reason (21). Modules with no failure mode a counter could see: pure decision functions (policy, security, net_policy, sched_policy), a flag (event), backpressure (channel), refusals that are the behaviour (forkguard, handle_transfer), dispatch tables, read-only reporting. Ratcheted as mustbezero_exempt, which may only fall. This is a judgement, and it is the weakest part of C2's "done when": the plan asked for a counter per module, and twenty-one modules have a sentence instead. Each reason is in check-subsystem.py's EXEMPT table for a reviewer to argue with.

Remaining honest gaps: the scheduler's requeue refusal is unreachable through the public API (the run queue is sized to the thread table) and is demonstrated by breaking the invariant by hand; and the ratchet is a textual heuristic - it says a counter is declared and, for the registry, asserted, not that its zero means the harm cannot happen.

Sabotage, finally run (2026-09-20). The plan's core-observability.txt said what would be caught and contained no cases: three claims nobody had made fail. It has them now, and running them found two things the file had been asserting falsely or hiding.

  • "A counter declared and never incremented" was not caught for the network stack: check-counters-produced.py watched nine structs and vibeos_inet was not one of them, so removing the only increment of sock_stale_parent left every check green - while the file's own comment said the check would catch it. Now watched; the case names vibeos_inet.sock_stale_parent.
  • "Printed and not asserted" and "a witness that says how often, not which" are red (mustbezero-asserted, and mbz_counters_missing when the witness leaves the [MBZ] line). The registry itself is broken two ways in core-mbz-registry.txt, both red.
  • The per-module must-be-zeros (network, mouse, ABI) each have two boot cases in core-observability-modules.txt - non-zero, and the line missing - all nine run and red with the intended reason. Running them found a witness naming the wrong object: the ABI's last_nr was overwritten by the deliberate probe, so an accidental call for 1998 was reported as 1999. It now records only unexpected numbers, and the case reports 1998. A witness that names the wrong thing is worse than a bare count, because it looks like an answer.
  • A limit in the registry's own proof, recorded rather than hidden: test_mbz_all_demonstrated asks about an id, not a call site, so removing one of a multi-site id's hooks stays green (the first sabotage of it did exactly that and scored NOT RED). It is covered for the single-site ids (ELF, log sink) and for at least one site of the rest. A per-site counter would close it and every site would need a test.

Sabotage. core-observability.txt — a counter declared and never incremented; a must-be-zero printed and not asserted; a witness that reports a count instead of an object.


C3 — the second dispatcher is fixed or deleted

Serves: maintainable.

Objective. kernel/core/syscall.c (1,477 lines) and kernel/proc/process.c (1,115) stop being a liability with a plausible name. The arch layer names vibeos_syscall zero times: nothing reaches this code.

The three defects, restated so a fix cannot be vague. Thread creation with no check on the caller. A process slot that is never freed — the same shape as the thread-slot leak, which surfaced as a fork refused on a machine with plenty of memory and took a day to attribute. And a caller identity read from an argument the caller supplies, which is not a weak security model but the absence of one.

The gate, and it is absolute.

Nothing here may become reachable from ring 3 before those three are closed. Not afterwards. Not in the same change.

If the answer is deletion this is satisfied trivially, and that is a point in deletion's favour.

How the decision gets made — it is C4's, not taste. C4 needs somewhere for the kernel's own syscall vocabulary. Either this code becomes that place with the defects closed first, or it is deleted and C4 builds the vocabulary from the live path. Decide it by asking which produces the smaller diff in C4, and write the answer down here before starting C4.

Sabotage. core-dispatcher.txt — if fixed: create a thread as another process, exhaust the slot table, lie about the caller id. If deleted: confirm check-reachable.py's baseline drops and that nothing references it.

Status (2026-09-20): done - by deletion, the gate enforced, and the dispatcher's orphaned vocabulary removed. The three headers left behind by the deletion (1,023 lines, included by two files that used nothing from them) went on 2026-09-20; before that C3 was done in the sense of its own criterion but left a second, dead vocabulary with a misleading name.

(Earlier status line, 2026-09-19:) done - by deletion, with the gate now enforced.

The decision, written down as the plan requires before C4 starts. Deleted, not fixed (commit 4462174, 2026-09-10). The rule was "which produces the smaller diff in C4", and measuring it went against integrating: both dispatchers were switch statements, C4 wants a table (a syscall id mapped to the checks that apply), so integrating meant carrying 1,502 lines of the wrong shape into the change meant to fix the shape. The twenty-three lines that were the right shape are kept as prose in docs/core/architecture.md. The deletion also established that vibeos_sec_* appears zero times in the arch layer: the kernel that boots has no capability model at all, so C4 is "there is none on the metal", not "move the policy layer down". check-reachable.py's baseline went 27 to 12 with it.

What this phase added today. The gate is absolute ("nothing here may become reachable from ring 3... not in the same change") and nothing enforced it: a baseline that only goes down is pleased when a new caller appears, because the unreached count falls. check-reachable.py now has a fence: no symbol defined in kernel/proc/process.c - the second process model that still carries the slot and caller-identity defects, and is C5's subject - may be named by kernel/arch, boot or user. kmain.c and waitset.c name a few of them and are allowed to: that is the portable kernel building and consulting its own table at boot, not a path a syscall takes. scripts/dev/cases/core-dispatcher.txt makes the arch layer name vibeos_proc_init; the run goes red with fenced: ... names vibeos_proc_init and passes again when restored. The fence lifts in C5.

Not done, and stated: the three defects themselves are still in process.c (closed by deletion for syscall.c, but process.c is kept). They are unreachable from ring 3, which the fence now guarantees rather than assumes.


C4 — one syscall vocabulary, ABIs as translators, and 41% of the file leaves

Serves: modular, maintainable, and it is the monster-file phase.

Objective. The kernel has its own syscall vocabulary with the permission checks attached to it; Linux becomes the first translator onto it; and the four ABI sections — 4,169 lines, 41% of arch_hw.c — move to kernel/abi/linux as part of that move rather than as a separate cut.

Why these are one phase. Splitting them was the previous plan's worst decision. Cutting the file first produces four smaller files with the same shape, which then all get rewritten when the ABI interface arrives. Building the interface first and moving the code through it does the work once. It is also the only version in which the cut is verifiable: code that moves behind an interface either satisfies it or does not compile.

Steps.

  1. vibeos_syscall_id — the kernel's vocabulary, not Linux's numbers.
  2. Each entry declares which checks apply. The checks stay where they are and are called by the dispatcher; they do not travel with handlers, because that is exactly how one choke point becomes several.
  3. vibeos_abi_t with classify, marshal, result. The existing Linux table becomes its first implementation — moved, not rewritten.
  4. A process records its ABI at creation. Bound once, not looked up per call — C0's syscall-entry histogram is what proves that claim rather than asserting it.
  5. The four sections move: Linux syscall layer (679), socket syscalls (1,654), what a program needs once running (651), what a C runtime asks for (1,185).

Done when. Adding a syscall without declaring its checks fails a host test rather than a review. A second ABI would be a table, not a code path. check-chokepoints.py still reports one site per check. check-blast-radius.py reports 1 for "add a syscall". And the [PERF] syscall histogram has not regressed past its margin.

Sabotage. core-syscall.txt — remove a check from a handler and confirm the enumeration names it; declare a check that is not run; run one that is not declared; add a second ABI and confirm no check was duplicated; look the ABI up per call and confirm the perf ratchet fires.

Status (2026-09-21): stages 1, 3 and 2a done (handlers register rows in their own files; "add a syscall" is 2 files, down from 6); 2b done (pointer arguments are declared in the row and checked by the dispatcher; hw_user_range_ok has one call site; error precedence moved - see core_c4_abi.md). Stage 1 is the vocabulary, the declarations and their enforcement; stage 3 moved about 4,000 lines out of arch_hw.c into kernel/abi/linux/ (12,174 to about 7,800), cut by reference graph rather than by line range, with the details in docs/implementation_progress/core_c4_abi.md. Stage 3 was done before stage 2 on purpose - see the reversal below - and it moved two metrics: "input device" went 4 to 5 (the handlers named the keyboard directly; fixed with a two-function console seam) and "add a syscall" was a deliberate 6 until stage 2a landed (now 2).

What exists now. include/vibeos/abi.h declares each operation once, with its checks, in an X-macro list (enum, names and check table cannot drift, and a line without its checks does not compile). kernel/abi/abi_linux.c is the Linux ABI as a translator: a constant nr -> operation table plus clone()'s flag decision, which is why classify takes the first argument. The dispatcher switches on the operation, not on Linux numbers; the ABI is bound to a task in hw_task_alloc, the one place a slot is claimed, and read from the task per call - not looked up. scripts/dev/check-syscall-checks.py (in check.sh) holds every declaration against the dispatcher's call graph in both directions: declared-not-run, run-not-declared, declared-never-dispatched. Its first run corrected three of my own declarations - exit, exit_group and prctl do touch user memory (the join word, a name string) and I had written NONE. Sabotage: core-syscall.txt, core-syscall-decl.txt - five cases, all red with the operation and check named.

Measured. [PERF] syscall_min is 229 cycles against a historical 208-440 and a 5,000 ceiling. The mean is not evidence (blocking inflates it - see C0).

What the first run also found. vibeos_linux_translate_syscall was being called on every syscall, its result stored in a variable nothing read - "configured and consulted by nobody" running on every call - and its [COMPAT] translated= counter printed a number that meant nothing. Both gone. The [MM] COW_STATS block was nested inside if (vibeos_compat_stats(...) == 0), so the memory counters printed only if a compat accounting call succeeded; that coupling is gone too. include/vibeos/syscall.h, syscall_abi.h and syscall_policy.h still held the deleted dispatcher's vocabulary - 1,023 lines that user/lib/user_api.c and a host test included and used no symbol of. Deleted (C3's last increment); the new vocabulary is named vibeos_op_id because the old word was taken.

Not done, and the two remaining stages.

  • Blast radius for "add a syscall" is 4, not the plan's 1 (it was 1 file before, with no declaration at all): abi.h, abi_linux.h, abi_linux.c and the handler plus its case in arch_hw.c. The regression is deliberate and recorded in check-blast-radius.py; getting to 1-2 means one row carrying number, operation and checks, which needs the handlers to leave arch_hw.c.
  • The checks are still inside the handlers. The plan's step 2 has the dispatcher call them, so that "one site per check" is one site. Today hw_user_range_ok has 46 call sites; the enumeration proves each operation reaches one, not that it validates the right pointer. Moving them needs each operation to declare its pointer arguments (which argument, how long, in which direction), which is a per-syscall rewrite of 44 sites and the riskiest part of this phase. Stage 2.
  • The four ABI sections were to follow stage 2 (so the 46 call sites would not be touched twice). Reversed by the user's instruction and, on reflection, for the better: the move is mechanical and compiler-verified, and stage 2 now edits smaller files it can hold in its head. The 46 call sites did travel, and stage 2 will touch them once, in their new home.

C5 — one owner for "what is a task"

Serves: maintainable, modular.

Objective. The task table has a single definition; the arch layer keeps only what a context switch needs. 1,363 lines, of which most leave.

Why after C4 - and why it was taken before it. It is the structure everything reaches into, so moving it early means every later phase edits it again. C4 settles what a process is from the syscall side; C5 then moves the structure once.

That ordering assumed the structure was shaped right and only in the wrong place. It was not. Five external findings in two days - the mapping cursor, the region list, signal dispositions, exit_group, and descriptors - were one defect: clone copied hw_proc_t by value, so everything that belongs to a process was a private copy per thread. Moving a structure with the wrong ownership model only relocates the defect, and C4's handlers would have been written against it.

So C5 now has a step 0, done first: process state is referenced, not copied (hw_procstate_t; see implementation_progress/core_c5_process_state.md). Its tests were written before the kernel was touched and failed on it. The move below is unchanged and still comes after C4.

Closed in step 0 (2026-09): WNOHANG, tkill, the blocking waits; exec in a threaded process ends the siblings and takes the leader's id (H-006, 8a3903c); a leader that exited before its threads is released when exec takes its slot; and hw_procstate_t has a must-be-zero - procstate_double_put, a process reference given back that nobody held, on the [TASKS] MUSTBEZERO line and asserted by the gate.

Partly closed: there is now one per-process address-space lock (hw_mm_lock, mm_busy), bounded so a missed release is a named panic not a hang. brk takes it, and fork takes it across its read of the parent's page tables and region list; munmap and mmap take it too (mmap's mutation was extracted into hw_mmap_locked so one lock/unlock covers its six returns). mprotect takes it across its region-list update (vibeos_vma_protect) and releases before its page-table narrowing pass. So fork is now atomic against all four mm calls on the region list.

The shootdown-under-lock deadlock is closed. fork holds the lock across clone_cow, which itself does a TLB shootdown - so the very hazard first noted for mprotect was already live in fork (d6fe3bc): a sibling spinning in hw_mm_lock is a shootdown target that cannot ack with interrupts masked, and will not leave the spin until fork releases - the shootdown's timeout panic. hw_mm_lock's spin now opens an interrupt window each iteration (sti; pause; cli) so it can service the shootdown IPI; the spin holds no lock, so a timer there is a safe preemption, and g_current_task is per-CPU so identity survives it. Interrupts are restored to the caller's state on acquire.

Steps 1-4 (2026-09-21): the identity is vibeos_task_t, the descriptors vibeos_fdtable_t, pipes a module, the state the task layer's alone. Seventeen fields of "who a task is" left hw_task_t for a portable, host-tested type with one reset. See implementation_progress/core_c5_task_identity.md, core_c5_pipes.md, core_c5_task_state.md.

Done (2026-09-22). The 92 lines that named identity outside the fields were code, not just fields - creating, describing, signalling and ending a task, spread through arch_hw.c beside the context switch. That code left, whole, for kernel/arch/x86_64/task_life.c (1,643 lines); check-task-identity.py now reads arch_hw.c function by function and only the five that are the context switch may name .id. at all (12 lines, ratcheted). See core_c5_task_state.md's closing section for what that found, including two checks (check-chokepoints.py's count and check-exec-layering.sh, which had a real bug of its own and no case) fixed on the way. arch_hw.c is 6,220 lines, down from 12,174 before C4.

Still open, and out of C5's scope as stated (recorded, not forgotten):

  • mprotect's page-table race with fork. Closed 2026-09-27 (M-071): mprotect holds the lock across the narrowing, and accepts a page in swap.
  • descriptors are still per thread. Closed 2026-09-27 (M-072): the table is the process's, in hw_procstate_t, and gated by THREADS_C5_FILES. See core_c5_residue.md.

Steps. vibeos_task_t holds identity, state, parent, exit status, credentials and descriptors. hw_task_t keeps ctx, kstack_*, cr3 and a pointer across. The state machine and its transition table move with it — they are the single source of truth about what READY means, and a second opinion is how two cores once ran one task.

Note the constraint the move must preserve: VIBEOS_HW_MAX_TASKS <= 64 is a static assertion because the runnable bitmask is 64 bits wide. It replaced a runtime check that had never once been false — which reads as a guard, costs a comparison per slot, and would have silently dropped slots rather than failing.

Done when. arch_hw.c names no task field that is not part of a context switch, [TASKS] MUSTBEZERO still reads zero, and the context-switch histogram has not regressed.

Sabotage. core-task.txt — publish a slot before its state is set; let two cores claim one task; free a kernel stack from the core standing on it. All three are defects this project has had; the move must not lose their guards.


C6 — diagnostics become a module

Serves: observable, and the monster file.

Objective. The kernel log (724 lines) and the crash records (56) move to kernel/diag. The UART stays behind a registered sink; everything above it is the same answer on any architecture.

Why it is worth its own phase. These two are the instruments every other phase is verified with, so moving them is the one move that can invalidate its own evidence. It goes late, after C2 has given the rest of the kernel detectors that do not depend on them, and its own verification is the interleaving check that already exists.

Sabotage. core-diag.txt — an unmatched unlock; a multi-part message left unbracketed; a sink that drops a line silently. The first two are defects this project has had, and one of them invented crashes that never happened.

Done (2026-09-22). kernel/diag/klog.c owns the ring, its lock, the one line builder and the sinks; kernel/diag/crash.c owns the crash ring and its dump. The UART and the disk are registered sinks; the arch keeps the UART, the backtrace, the core parking and the capture. A sink gets one complete line per call, so an unbracketed multi-part line is not expressible; a refused line is klog_line_lost; a line the device dropped while reporting success is a gap in the serial sink's ln= numbers, which the gate checks. Moving it found that neither ring had ever been locked, and that the trap model wrote a false FATAL into the ring on every ring-3 fault. The cases are diag-klog.txt, diag-crash.txt and diag-sink.txt (check-subsystem.py pairs cases with modules by area). See implementation_progress/core_c6_diag.md.


C7 — the registries the next refactor needs

Serves: modular. This is the phase the driver refactor is waiting on.

Objective. Character, input, network and display devices register the way block devices do. check-blast-radius.py reports 1 for every row of its table.

Not in scope: isolation, hot-plug, dynamic loading. Those are the driver refactor's subject. What C7 owes it is that adding a driver does not mean editing the core — true for storage today, false for everything else.

Done when. The GUI is registered rather than wired in, and has all seven parts: its own lock, its counters, a must-be-zero, and a case file. It is the subsystem with none of those today and the prime suspect for corrupting another process's memory, which is the whole argument in miniature.

Done (2026-09-23). Drivers are descriptors in their own files, collected from a linker section; the registry routes, probes, hands out interrupt vectors, dispatches interrupts, serves the input, network, block and display classes, and provides driver locks. Input device, network interface, block driver, registered filesystem and display all measure 1. The GUI is kernel/io/gui.c with the seven parts, a torture against a model under real threads, and four case files. Not 1: syscall at 2 by decision. Filesystem (direct) was 4 - ext2, NTFS, exFAT and ISO9660 in storage.c's probe table - and is 1 since 2026-09-27: each declares itself with VIBEOS_FS_DRIVER and an order, and the volume supplies its state. See implementation_progress/core_c7_devices.md.


Already done, recorded so it is not re-planned

The scheduler decides and the arch layer switches. hw_pick_next asks vibeos_sched_policy_pick first and vibeos_runq_pick as a fallback — a fallback rather than an idle, deliberately, because a task the policy was never told about is an admission bug and refusing to run it turns that bug into a hang instead of a slower machine. charged + idle == seen watches it.

The memory bridge left arch_hw.c (2026-09-28). Paging and the identity map, the frame pool and its bring-up, the TLB quarantine, allocation with admission, the lock adapters each portable layer is given, the vmspace backend, the swap bridges and the page cache's wiring - about 2,000 lines, moved whole to kernel/arch/x86_64/mm_bridge.c with nothing changed in the move. Nearly every memory defect of the reclaim work (M-056..M-073) was fixed in those lines while they sat in a file whose functions are almost all static, which is what makes a hung core's nearest-preceding-symbol name unreliable. What crosses between the two files - nine functions and eight variables - is declared in arch_hw_internal.h under "the memory bridge", so the coupling is a list that can be read and shrunk. The two case files whose code moved (mm-tlbq-holders.txt, mm-swap-check.txt) were re-pointed and re-run against the new file.

The second half followed the same day: user-memory validation (hw_user_range_ok and its reasons, hw_user_addr_ok), the page-table primitives, the TLB shootdown and its wait, the copy-on-write fault and fork's copy, and the walk that asks whether a frame is still mapped - about 680 lines that sat under arch_hw.c's "shared with the Linux ABI layer" banner, which is the rule about banners again. The copy-on-write record and its counters moved with the fault that writes them. Five of the first half's crossings stopped crossing and are private again (hw_tlb_shootdown, hw_frame_still_mapped, hw_tlbq_put, hw_swap_bring_in, g_aspace_being_destroyed); the list gained the fault itself, for the trap handler, the two copy-on-write counters the report prints, and three flags of the SMP code the shootdown reads. arch_hw.c is 3,940 lines, mm_bridge.c 2,736.

The context switch left too (2026-09-28). Kernel stacks and their parking, slot allocation and release, the pick, the x87/SSE register file, hw_task_load_cpu_state with its CR3 guard, the context check and hw_schedule - about 800 lines, moved whole to kernel/arch/x86_64/task_switch.c, beside task_life.c. The pipe lock and the two console helpers that sat inside the same banner stayed. Four of the five functions check-task-identity.py allows to name a task's identity moved with it, so the check now reads both files against the same ratchet of 12 lines; a check whose subject leaves the file it reads keeps passing having stopped looking. Two sabotage cases prove it reads both: identity put back into the slot allocator (now in task_switch.c) and into a console helper (still in arch_hw.c) each turn it red, naming the function. arch_hw.c is 3,119 lines.

The banner "APIC + SMP bring-up" covered 640 lines, of which 145 were that. The application-processor bring-up - the bootstrap core's local APIC, the ISA lines routed on the IO-APIC, every other core woken and given an idle task - moved to kernel/arch/x86_64/smp.c (2026-09-28). It did not go into apic.c: that is the hardware driver and knows nothing of tasks, and this code needs the per-CPU table and the idle tasks. The APIC driver's declarations, which were written inline in arch_hw.c, went with it; only the end-of-interrupt stayed. One sabotage case (io-device-boot-smp.txt, split from io-device-boot.txt) was re-run on the new file. arch_hw.c is 2,961 lines.

The rest of that banner was starting userland: the supervisor's manifest, init and the first tasks, the scheduler armed, the wait for every user task to retire, and the end-of-userland report the gate reads most of its counters from. It moved to kernel/arch/x86_64/userland.c with its entry point, vibeos_x86_64_hw_start_userland, so hw_sched_bringup is private again and only the saved boot information crosses back. Seven sabotage cases anchored in the report moved with it - three files re-pointed, diag-sink.txt split into diag-sink-klog.txt - and all seven were re-run red on the new file. arch_hw.c is 2,515 lines. The memory totals kernel/mm/usage.c asks for (vibeos_mm_bytes_*), the last thing under that banner, went to mm_bridge.c: 2,478 lines.

The machine's diagnostics left too (2026-09-29). The kernel log's serial device, the backtrace, hw_panic and the park of every core, the lock-deadlock report, the per-CPU panic summary, the vanished-mapping walk and the crash dump

  • about 500 lines from two places in arch_hw.c - moved whole to kernel/arch/x86_64/diagnostics.c, the machine half of kernel/diag. Five names cross back, all used by what stays: the trap handler, the spin lock and early init. diag-sink.txt's four remaining cases followed the serial sink and were re-run red on the new file. arch_hw.c is 1,993 lines.

And the network bring-up (2026-09-29). The interface the portable stack drives, the pump the timer calls, and DHCP with its stated fallback - 200 lines

  • moved to kernel/arch/x86_64/net_bringup.c, the twin of io_bringup.c. Two names cross back: the pump, for the timer interrupt, and the bring-up, for early init. No case was anchored in it; the gate's DHCP lease and TCP round trip cover it. What is left in arch_hw.c - 1,792 lines - is what the name promises: the CPU (GDT, TSS, per-CPU state), the spin locks, the IDT with the PIC and the PIT, the trap handler, the syscall MSRs and early init.

What this plan still does not do

It does not make kernel/core/ portable to a second architecture. There is no second architecture, and building for one that does not exist is exactly how the present portable kernel became untested and unreachable.

It does not set a target line count for arch_hw.c. 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. The criterion is blast radius; the file shrinks as a consequence, by about 7,700 lines if every phase lands.

Devices: discovery, drivers and peripherals

Status: Proposed Separated from docs/io/ deliberately. Storage is one consumer of device discovery; the network is another and the peripherals are a third, so putting enumeration inside the storage plan would make two of its three users depend on a plan that is not about them.

This is a survey, not a plan yet. The phases are deliberately not written: storage comes first, and a second detailed plan running beside it would invite working on both. What is here is the set of facts a plan would be built on, recorded now because they were found now and would otherwise be found again.

What is actually there

A survey, not an impression.

There is no PCI layer. pci_read32 and a bus scan are written out three times — in ahci.c, virtio_blk.c and virtio_net.c — and 0xCF8 is defined in each of them. Every future driver would write it a fourth time.

This is the same shape as the three parallel read paths the storage plan opens with, and it has the same consequence: three copies of one fact, which is how they come to disagree. The AHCI driver already carries a comment about matching by PCI class rather than device id, because VirtualBox emulates an ICH8 and QEMU an ICH9 — that knowledge lives in one driver and nothing else can use it.

AHCI does not know how big its disk is. virtio-blk reads its capacity from the device configuration and keeps it; AHCI never issues IDENTIFY. So every AHCI read today is unbounded: an LBA past the end of the medium goes straight to the controller. The block layer built in I0 refuses to register a device that cannot say its size, which makes this a prerequisite rather than an improvement.

The peripherals are legacy, and there is no USB at all. PS/2 keyboard and mouse, a framebuffer, a GUI on top of it, and the serial port. Nothing enumerates a bus to find them — they are at fixed addresses because they always have been. That is fine for what they are, and it is worth writing down that "external peripherals" on this machine currently means "the two devices IBM put on a PC in 1984".

Interrupts are wired by hand. Each driver knows its own vector. There is no allocation, no sharing, and nothing that could route an MSI.

The goal

Production ready. A machine that says which hardware it found and which it could not drive, rather than failing later in a way that looks like a missing file. This project has that failure in its history: the appliances imported and booted and then every exec failed, because the kernel could only talk to virtio-blk and nothing said so.

Observable. A boot that lists what is on the bus, what claimed it, and what was left unclaimed. Today a device nobody drives is invisible.

Maintainable. One PCI implementation. One place that knows how a driver is matched to a device.

Extensible. Adding a driver means writing a table and a match rule. Adding USB later means adding a bus, not editing the drivers that already work.

Where the boundary with storage is

The line is between finding a device and driving one.

PCI enumeration is here. It serves storage, the network and eventually the peripherals, so putting it in the storage plan would make two of its three users depend on a plan that is not about them.

A block driver knowing its own capacity is storage, and is already needed: the block layer built in I0 refuses to register a device that cannot say how big it is, because a device with no size cannot have its requests bounds-checked. AHCI issuing IDENTIFY is therefore part of I1, not of this.

What a plan here would not do

It would not implement USB. It would make USB possible by giving a bus something to plug into — but a host controller, a device stack and a mass-storage class driver is its own project, and putting a large unknown beside phases that are measurable is how a plan stops being one.

ADR-0001: Hybrid Kernel Model

Status

Accepted

Context

VibeOS requires:

  • strong isolation for risky components
  • low overhead on kernel hot paths
  • room for compatibility runtimes and service-oriented evolution

A pure monolithic kernel conflicts with the security and compatibility goals. A pure microkernel increases risk for early implementation complexity and performance regressions if IPC and service orchestration are not yet mature.

Decision

VibeOS adopts a hybrid microkernel-inspired modular architecture:

  • kernel core remains small and primitive-oriented
  • user-space services host drivers, filesystems, policy engines, and compatibility runtimes by default
  • privileged execution is allowed only for boot-critical, interrupt-critical, or benchmark-justified components

Consequences

Positive:

  • smaller trusted computing base than a monolithic design
  • safer placement for compatibility layers
  • controlled path for restartable services

Negative:

  • architectural governance burden increases
  • IPC performance becomes a first-order success factor
  • some subsystems may need later relocation based on measurement

Implementation notes

  • placement exceptions require benchmark evidence
  • subsystem contracts must be explicit and versioned
  • the first design review in Phase 2 must verify that early bootstrap code does not create accidental permanent kernel dependencies

ADR-0002: Platform Bring-Up Baseline

Status

Accepted

Context

The project needs a constrained initial target to reduce bring-up complexity and enable fast iteration in emulator-based workflows.

Decision

The initial platform baseline is:

  • x86_64
  • UEFI firmware
  • SMP enabled from the first bring-up architecture
  • QEMU as the primary validation environment

The project-owned bootloader remains the long-term target, but early bring-up may temporarily use a standards-compliant boot handoff if it accelerates kernel validation without importing foreign kernel code.

Consequences

Positive:

  • faster path to early kernel execution
  • reproducible development environment
  • direct alignment with CI and emulator-driven testing

Negative:

  • legacy BIOS is deferred
  • ARM64 work is deferred to a later portability phase
  • some real hardware issues may appear after emulator success

Implementation notes

  • boot information handoff must be versioned from the start
  • early serial output is mandatory
  • the first hardware profile should remain intentionally narrow: timer, interrupt controller, framebuffer or serial, PCIe discovery, and one storage path

ADR-0003: Handle-Based Native ABI

Status

Accepted

Context

The OS must support multiple compatibility environments without bloating the native kernel ABI. A small, orthogonal API surface simplifies security review and long-term maintenance.

Decision

The native syscall ABI is handle-based and object-oriented:

  • kernel resources are represented as typed objects
  • user space references them through handles carrying explicit rights masks
  • foreign ABI behavior is translated above this interface

The native kernel ABI shall not attempt to expose Linux, Win32, or Darwin syscalls directly.

Consequences

Positive:

  • cleaner rights enforcement
  • easier policy mediation at object boundaries
  • compatibility layers can evolve independently of native ABI shape

Negative:

  • compatibility runtimes must do non-trivial semantic translation
  • some foreign descriptor or handle conventions may be awkward to map

Implementation notes

  • initial object set should include process, thread, address space, channel, event, timer, memory object, and device endpoint
  • handle transfer through IPC must preserve rights semantics

ADR-0004: Scheduler Foundation

Status

Accepted

Context

The system targets multicore performance from the start but cannot afford scheduler complexity that blocks bring-up.

Decision

The first scheduler implementation uses:

  • preemptive scheduling
  • priority bands
  • per-CPU run queues
  • round-robin within priority bands
  • SMP load balancing with affinity awareness

The design must preserve an upgrade path to weighted fairness, topology awareness, and later real-time classes.

Consequences

Positive:

  • implementation remains tractable for early milestones
  • contention is reduced relative to a single global queue
  • wake-up and balancing logic can evolve incrementally

Negative:

  • fairness is approximate in early versions
  • topology and energy awareness are deferred

Implementation notes

  • starvation prevention via aging is required even in the first version
  • scheduler tracing hooks are mandatory so later tuning is evidence-based
  • preemption and timer behavior must be testable in QEMU

ADR-0005: Virtual Memory Model

Status

Accepted

Context

The OS must support strong isolation, future compatibility runtimes, memory-mapped files, and multicore-safe execution.

Decision

The virtual memory model uses:

  • per-process address spaces
  • higher-half kernel mapping
  • direct physical map for kernel internal operations
  • explicit memory objects backing mappings
  • copy-on-write support
  • file-backed and shared-memory mappings

Consequences

Positive:

  • clear ownership and accounting for mappings
  • strong base for compatibility runtimes
  • natural integration with page cache and filesystem services

Negative:

  • VM subsystem design is more complex than a minimal flat mapper
  • TLB invalidation and mapping lifecycle need careful SMP handling

Implementation notes

  • the first bring-up should prioritize early paging correctness, kernel map stability, and user-space address-space creation
  • demand paging may be staged after initial static mapping support if needed for schedule control

ADR-0006: User-Space-First Drivers and Filesystems

Status

Accepted

Context

Drivers and filesystems are major sources of complexity, parser risk, and fault amplification in operating systems. VibeOS also needs a natural place for compatibility-related mediation and policy enforcement.

Decision

Drivers and concrete filesystem implementations default to user-space hosts or services.

Kernel-space exceptions are allowed only when:

  • required before trusted user-space exists
  • the interrupt or latency path cannot tolerate the boundary
  • benchmark evidence justifies privileged placement

Consequences

Positive:

  • failure containment improves
  • parser-heavy code stays outside the kernel
  • service restart and supervision become possible

Negative:

  • IPC and shared-memory data paths become performance-critical
  • DMA and MMIO mediation need careful design

Implementation notes

  • the device manager and filesystem service contracts must be versioned
  • storage and network fast paths should be re-evaluated after profiling, not guessed in advance

ADR-0007: Centralised Memory Management

Status

Proposed

Context

One defect - a physical frame released while another address space still maps it

  • has been diagnosed four times and fixed three times. It is still present.

The fixes were each real: reference counters made atomic, a table whose length did not cover the region it indexed, a munmap that freed frames without consulting the count at all, and a redesign of what the count meant. None of them closed the defect, and the last one is the informative case: the count is now internally consistent - when the free-while-mapped detector fires, owners_after_put=0, so the arithmetic agrees the last owner let go - and still wrong about the world.

The reason is not arithmetic. Ownership is inferred rather than recorded. The kernel decides "this address space owns this frame" by reading hardware bits that mean something else: PTE_USER means ring 3 may touch the page, and PTE_PRESENT means the translation is valid. Neither means ownership. Two mistakes three days apart came from that gap - counting on PTE_USER missed PROT_NONE guard pages (211 frames per boot with no owners), and releasing on PTE_PRESENT released the 511 identity entries left by splitting a 2 MiB leaf, decrementing other address spaces' counts.

Compounding it, there is no single owner of a mapping. Page table entries are written directly in eight places and torn down in two, with different rules for the two user windows. Adding counters to two of those cannot make the other eight correct.

Decision

Rewrite the memory manager as five layers with one owner each, rather than continue to patch the current arrangement:

  • L0 frames - the only code that touches the free list or a reference count.
  • L1 address spaces - the only code that writes a page table entry.
  • L2 regions - what a process asked for, so munmap stops walking page tables and freeing what it finds.
  • L3 backing stores - anonymous, page cache, swap, behind one interface.
  • L4 policy - reclaim, LRU, eviction order.

Ownership becomes explicit: a software-available bit, PTE_OWNED, set only by the mapping function and released only by unmap and teardown. PTE_USER and PTE_PRESENT go back to meaning only what the hardware says.

The work is phased P0 to P7, each phase shippable and green, with P2 the phase that repairs the defect and everything after it new capability. The goal is production ready as defined in the plan: correct under concurrency, bounded, survives exhaustion, no partial states, isolating, predictable, observable, documented and tested - with each property mapped to a test that has been seen to fail.

The full plan is docs/mm/.

Consequences

Good. Ownership is recorded once and cannot be re-derived differently by the next call site. The frame and region layers become pure data structures and can be tested without booting a machine, which the current memory code cannot. Counters for the page cache, swap and reclaim exist from the first phase, so those subsystems are additions rather than excavations. A layering check in CI makes the structure enforceable rather than aspirational.

Costly. The frame descriptor grows from one byte to sixteen per frame - 1.7 MiB on the 440 MiB this kernel sees, or 0.4%. The memory code has to be extracted from a 7800-line file while remaining bootable at every commit.

Risky. A rewrite of a working subsystem mid-project. Mitigated by ordering: the repair (P2) lands before any new capability, each phase is revertible, and the existing detectors - free-page poison, free-while-mapped, the seeded stress service - stay in place throughout and move into L0 where they belong.

Deferred. Allocator sophistication (buddy, slabs, per-CPU caches) is explicitly out of scope; the plan makes room and stops. Whether the layers live in kernel/mm/ or in kernel/core/ - which settles what the portable kernel is for - is recorded as a decision for the maintainer, not for the implementer.

Architecture Decision Records

This directory contains Architecture Decision Records derived from the consolidated software architecture specification.

Current ADR set:

  • ADR-0001-hybrid-kernel-model.md
  • ADR-0002-platform-bringup-baseline.md
  • ADR-0003-handle-based-native-abi.md
  • ADR-0004-scheduler-foundation.md
  • ADR-0005-virtual-memory-model.md
  • ADR-0006-user-space-first-drivers-and-filesystems.md

Rules:

  • each ADR records one primary decision
  • status changes must be explicit
  • superseded ADRs remain in history
  • implementation work should reference the relevant ADR identifiers

Archive

Documents that described the project at a moment that has passed. Kept because they are the record of what was believed and planned then, not because they describe the kernel now - for that, start at implementation_progress.md and the plan directories (mm/, sched/, exec/, io/, core/, abi/).

DocumentWhat it wasSuperseded by
project_status_assessment_2026-04-03.mdA status snapshot from when the kernel ran only as a host simulationimplementation_progress.md
architectural_review_for_developer_2026-04-03.mdThe architecture review of the same datethe plan directories, and the reviews in implementation_progress/
storage_plan.mdThe storage plan, written when FAT was called directly from twenty places and there was no filesystem layerio/, which carried it out
test_automation_spec.md, test_feedback_profiles.mdA test-automation design whose profiles were never builttesting_strategy.md and scripts/dev/README.md, which describe what runs
milestones_2026-08.mdThe milestone table from roadmap/, last snapshot 2026-08-03roadmap.md and implementation_progress.md
risk_register_2026-04.mdThe April risk register from roadmap/the open rows of review_findings_tracker.md

Moved here on 2026-09-29, unchanged. The top-level roadmap/ directory held the last two and no longer exists: one place for documentation is easier to keep true than two.

Architectural Review For Developer (2026-04-03)

1. Valutazione generale dell'architettura

  • Coerenza con le specifiche: Parziale (tendente ad Alta sulla foundation)
  • Stabilità architetturale: buona sulle primitive core; ancora fragile su bring-up runtime reale
  • Scalabilità prevista: buona da design, non ancora validata su SMP/hardware reali
  • Manutenibilità: alta su struttura modulo/documentazione; media sulle integrazioni ancora stub

Sintesi: architettura coerente con il modello ibrido previsto, ma validata principalmente in host simulation.

2. Conformità alle specifiche

Componente / ModuloSpecifica previstaImplementazione attualeConformitàNote
Kernel model ibrido/modulareCore minimo in kernel, servizi in user-spaceCore + servizi user-space presentiConformeAllineato SAS
Boot systemBootloader UEFI reale + handoffBoot stub e builder boot infoParzialmente conformemanca boot reale
Process managementlifecycle, isolamenti, object model completolifecycle + PID handle isolation + duplication policyParzialmente conformemanca integrazione completa thread/process object
Schedulerpreemptive per-CPU e multicorerunqueue per-CPU + preemption baseParzialmente conformeSMP reale mancante
Memory managementPMM + paging/COW/fault pathPMM bump + VM map/protect/unmapParzialmente conformebackend paging mancante
IPCchannel/event/waitset + transfer sicuroimplementato con ownership/lifecycle/timed waitsConformeforte per fase
Syscall interfaceABI handle-based versionatadispatcher + policy rights + PID enforcementParzialmente conformeABI/versioning da formalizzare
Security modelcapability + MAC-likecapability/token/policy baseParzialmente conformeMAC/propagation incomplete
Driver architectureuser-space-first + supervisionframework/devmgr/host stubParzialmente conformeruntime supervision da maturare
FilesystemVFS + native FS integrityVFS runtime baseParzialmente conformenative FS non implementato
Networkingstack TCP/IP progressivosocket/runtime baseParzialmente conformestack completo mancante
Compatibility layersLinux/Windows/macOS demosolo strutture e pianoNon conformenessuna demo eseguibile
Observabilitylogging/tracing/debug robusticontatori + diagnostica test baseParzialmente conformetracing framework assente
Testing strategyCI ripetibile + smoke realitest host-side verdiParzialmente conformeCI/smoke emulatore incompleti

3. Deviazioni architetturali rilevate

DeviazioneMotivazione possibileImpatto tecnicoGravità
Validazione soprattutto host-sidevelocità di iterazione inizialemismatch possibile con runtime realeAlta
Limite VIBEOS_MAX_PROCESSES=32workaround stack/testscala limitata e artificialeMedia
Evoluzione argomenti syscall senza ABI congelatasviluppo incrementalerischio regressioni interfacciaMedia
Compat layer non implementatipriorità foundation kernelmilestone M8/M9 non supportateAlta
Bootloader reale assentefocus su primitive coreblocco validazione M2/M3Alta

4. Criticità PM valutate come architettura/tecnica

ProblemaTipoImpatto sull'architetturaAzione consigliata
CI/CMake non stabile end-to-endTecnico/organizzativovalida poco la qualità architetturalestandardizzare toolchain e pipeline unica
Gap host simulation vs bare-metalTecnico/architetturalerischio decisioni non trasferibiliintrodurre smoke QEMU/UEFI obbligatori
Milestone avanzate non sostenute da artefatti runtimeStrategicodisallineamento roadmap/architetturaribaseline milestone con DoD tecniche
KPI temporali mancantiOrganizzativoforecasting deboleintrodurre metriche delivery/quality

5. Qualità tecnica del sistema

  • Modularità: buona
  • Separazione responsabilità: buona in design, parziale in runtime
  • Accoppiamento: medio-basso
  • Riusabilità: buona sulle primitive
  • Testabilità: buona host-side, debole bare-metal
  • Gestione errori: basica ma coerente
  • Sicurezza: direzione corretta, enforcement ancora incompleto
  • Performance: strutturalmente buona, non misurata su target reale

Debiti tecnici principali: ABI syscall non congelata, boot reale assente, paging backend mancante, compat layer non operativi.

6. Rischi tecnici e architetturali

RischioProbabilitàImpattoPriorità
Divergenza host test vs runtime realeAltaAltaAlta
Rework su VM/fault modelMedia-AltaAltaAlta
Scope creep compatibilità multi-OSAltaAltaAlta
Sicurezza incompleta (MAC/token propagation)MediaAltaAlta
ABI syscall instabileMediaMedia-AltaMedia-Alta
Crescita complessità senza CI gateAltaMediaMedia-Alta

7. Modifiche architetturali raccomandate

ModificaMotivazioneImpattoPriorità
Congelare syscall ABI v0prevenire regressioni interfacciastabilità altaAlta
Object manager unificato process/thread/handlechiudere integrazione lifecyclecoerenza sicurezza/lifecycleAlta
Waitset su waitqueue/time source realeridurre simulazionecorrettezza scheduling/latencyAlta
Policy ADR obbligatoria per eccezioni kernel-spaceevitare erosione confinigovernance architetturaleMedia
Compatibility adapter con demo minimaprogressi misurabili M8/M9riduce rischio roadmapMedia

8. Debito tecnico identificato

Debito tecnicoImpatto futuroComplessità di risoluzionePriorità
Bootloader UEFI reale mancanteblocca milestones baseAltaAlta
VM avanzata assente (paging/COW/fault)limita sicurezza/performanceAltaAlta
CI smoke emulatore assenteregressioni tardiveMediaAlta
MAC e token propagation incompletisecurity-first non raggiuntoMedia-AltaAlta
Compatibilità runtime non implementatarischio strategico su obiettiviAltaMedia-Alta
Limite processi workaroundriduce validità stress testBassa-MediaMedia

9. Raccomandazioni operative per sviluppo

Azioni urgenti

  1. Definire e documentare syscall ABI v0 (arg semantics per syscall group).
  2. Implementare smoke test QEMU/UEFI in pipeline CI.
  3. Chiudere M2/M3: boot reale, timer/interrupt reali, paging base reale.

Miglioramenti consigliati

  1. Chiudere handle-based process/thread object integration.
  2. Implementare revocation propagation su handle duplicati.
  3. Rafforzare security layer (token propagation + MAC minima).

Ottimizzazioni future

  1. Framework benchmark (latenza syscall, IPC RTT, fairness scheduler).
  2. Evoluzione scheduler (aging, balancing, SMP reale).
  3. Primo demo compatibilità Linux CLI con scope controllato.

Milestones

Current Snapshot (2026-08-03)

MilestoneStatusEvidence
M0 project charterCompletedPhase 1 documents
M1 toolchain and buildCompletedgcc/clang x Debug/Release matrix, green on every push
M2 boot to kernel bannerCompletedUEFI boot gate under OVMF, required in CI
M3 memory and interruptsCompletedpaging, traps, PIT and APIC timer, all boot-verified
M4 scheduler and multitaskingCompletedpreemptive SMP across all cores, per-process address spaces
M5 first user-space serviceCompletedring-3 programs with fork/exec/wait
M6 storage and shellCompletedvirtio-blk, FAT read and write, serial shell
M7 networked native systemCompletedTCP/IP over virtio-net, TCP round trip to a host server in CI
M8 Linux CLI compatibilityCompletedunmodified static BusyBox runs echo, cat and ls; its shell parses scripts, searches PATH, forks and execs. All four gated in CI
M9 process semanticsCompletedsignals delivered and verified by a program built against glibc; copy-on-write fork measured at 1221 pages shared to 24 copied
M10 a machine with a screenIn progressPS/2 mouse on IRQ12, framebuffer desktop, on-screen terminal mirroring the console. Gated on state; the pixels are still checked by hand
M11 graphical user spaceNot started-
M12 usable without a serial cableNot started-
M13 dynamic executablesDoneET_DYN loaded at a bias, PT_INTERP mapped alongside, and a dynamically linked musl binary runs in the boot gate
M14 threadsDoneclone(CLONE_THREAD), futex wait/wake, tgid/tid and thread exit; four threads share memory under a contended mutex, lose no increment and keep their own thread-locals, gated on every boot
M15 Windows console compatibilityNot started-
M16 durable storagePartly donejournal, block cache and device barrier built and swept against power loss; no filesystem routes its metadata through it yet
M17 architecture review for expansionOngoingdesign documents updated alongside the code

Status here reflects what boots and passes a gate, not what has been designed. A milestone moves to Completed when something fails if it regresses.

Two notes on how this list changed. The original M9 and M10 were Windows compatibility and an architecture review; the work that actually happened - process semantics, then drivers and a screen - had no milestone at all, which made the roadmap describe a different project from the one in the repository. Windows compatibility has moved down rather than away: it was never started, and putting it after the things this system needs to be usable is a statement about order, not about scope.

The April assessment (docs/project_status_assessment_2026-04-03.md) predates most of this work and is kept as a historical record rather than a current view.

M0: project charter complete

  • all Phase 1 documents created
  • repository structure agreed
  • architectural choice documented

M1: toolchain and build skeleton

  • cross compiler selection
  • build scripts
  • image packaging
  • emulator launch flow
  • first automated boot smoke test

M2: boot to kernel banner

  • bootloader loads kernel
  • serial output works
  • early memory map parsed

M3: memory and interrupts online

  • IDT or equivalent configured
  • paging stable
  • physical allocator functional

M4: scheduler and multitasking

  • threads run and preempt
  • timer tick or event scheduling works
  • SMP bootstrap on x86_64

M5: first user-space service

  • init process starts
  • IPC path usable
  • service restart path proven
  • service manager and device manager boundaries validated

M6: storage and shell

  • filesystem mounted
  • shell executes native commands
  • file I/O stable

M7: networked native system

  • TCP/IP available
  • remote diagnostics or package ingress possible

M8: Linux CLI compatibility (completed)

Exit criterion was "shell utilities or a small Linux user program run under compatibility mode". Delivered, and gated:

  • a static musl binary and a static glibc BusyBox, neither built by this project, load at the address they were linked for and reach main
  • BusyBox dispatches on its own name and runs echo, cat and ls, reaching the filesystem through openat, fstat, read and getdents64
  • its shell parses a script, runs builtins, searches PATH, forks and execs
  • the boot fails on busybox_did_not_run, busybox_applet_dispatch_failed, busybox_file_operations_failed and interactive_shell_did_not_run

M9: process semantics (completed)

Not in the original plan. It became a milestone because two things a real program assumes were missing, and both are invisible until something depends on them:

  • signals are raised, masked, queued and delivered on the way back to user space, with the frame built on the process's own stack below the red zone. Verified by a program written against Linux, so the return trampoline comes from a real C library rather than from a test that agrees with us
  • fork shares pages copy-on-write instead of copying them. Measured: 1221 pages shared, 24 later duplicated. A shell forks for every command and the exec after it discards the copy, so eager copying was work guaranteed to be wasted

M10: a machine with a screen (in progress)

  • done: PS/2 mouse driver on IRQ12 with packet resynchronisation, a framebuffer desktop with a pointer, and a window that mirrors the console so the machine shows what it has been saying on the serial line
  • done: the boot gate fails if the on-screen terminal stayed empty
  • remaining: typed characters still reach only the shell; Ctrl-C becomes SIGINT but that path is not yet exercised by a test
  • remaining: the pixels are checked by scripts/dev/screenshot.py, run by hand. A gate that a human has to remember to run is not a gate

M11: graphical user space

The GUI is currently kernel code, which makes it a drawing rather than a window system. Exit criterion: a ring-3 program draws its own window and receives its own input.

  • syscalls for querying the screen, presenting a buffer, and reading input
  • input routed to a focused window rather than to whatever reads the console
  • the desktop itself becomes one of those programs

M12: usable without a serial cable

The point at which someone can sit in front of the machine and use it. Exit criterion: boot to a shell in a window, type, and get output, with no serial console attached.

  • keyboard into the terminal window, including editing
  • dup, dup2, pipe2 and poll, without which a shell cannot do ls | wc
  • the boot self-test driven through that path, so it is gated rather than demonstrated

M13: dynamic executables

Today ET_DYN and PT_INTERP are refused, so "runs Linux programs" means static ones. Exit criterion: a dynamically linked binary runs.

  • load the interpreter, hand it the auxiliary vector, let it relocate
  • mmap with file backing, which needs a page cache
  • shared library search and openat on a real directory tree

M14: threads

clone with CLONE_VM returns ENOSYS and futexes are answered honestly rather than implemented. Exit criterion: a threaded program runs correctly under contention.

  • threads sharing an address space, with per-thread TLS and kernel stacks
  • real futex wait and wake
  • set_robust_list starts meaning something

M15: Windows console compatibility

Unchanged in scope, moved in order. A PE loader and enough of the NT user-space surface for a console executable, in a subsystem outside the kernel.

M16: durable storage

FAT has no journal, so a write interrupted by a reset can leave the volume inconsistent. Exit criterion: power loss during a write does not corrupt the filesystem.

  • a block cache with write-back and barriers - done, and the barrier now reaches the device, because a drive acknowledges a write when it reaches its own volatile cache and not before
  • a test that cuts power mid-write and checks the result - done, as a sweep rather than a single case: a fake drive stops accepting writes after exactly N of them, for every N a transaction performs and under several flush orderings, and the volume must come back all-old or all-new
  • a filesystem with ordered metadata updates - not done. The journal exists, is tested and recovers at mount, but no driver stages its metadata through it yet. Until that connection is made, this milestone describes a mechanism and not a volume, and FAT writes remain as crash-unsafe as before.

M17: architecture review for expansion

  • reassess kernel and service boundaries now that there is a user space
  • validate the security posture: there is one identity, and it is root
  • review the risk register and compatibility scope
  • decide ARM64

Project Status Assessment (2026-04-03)

1. Sintesi dello stato del progetto

  • Stato generale: A rischio
  • Percentuale stimata di completamento: ~42%
  • Sintesi: foundation architetturale e kernel host-side solide, ma milestone runtime reale (boot reale, hardware integration, compatibility demo) ancora non raggiunte.

2. Confronto Piano vs Realtà

MilestoneStatoRitardo stimatoNote
M0 project charter completeCompleted0Documentazione architetturale completa e coerente.
M1 toolchain and build skeletonPartialMedioBuild/test host-side disponibili; packaging/emulator smoke non chiusi end-to-end.
M2 boot to kernel bannerPartialMedio-AltoBootloader reale non ancora validato.
M3 memory and interrupts onlinePartialMedioPrimitive presenti, integrazione hardware reale incompleta.
M4 scheduler and multitaskingPartialMedio-AltoScheduler base presente; SMP bootstrap non completato.
M5 first user-space servicePartialMedioServizi e IPC presenti a livello base, ma non dimostrati in runtime reale.
M6 storage and shellNot startedAltoMancano shell e stabilità I/O completa.
M7 networked native systemPartialMedio-AltoSocket/runtime base disponibili, non stack production-grade.
M8 Linux CLI compatibility demoNot startedAltoPredisposizione architetturale, nessuna demo eseguibile.
M9 Windows console compatibility demoNot startedAltoPredisposizione architetturale, nessuna demo eseguibile.
M10 architecture review for expansionPartialBassoPrerequisiti di review parzialmente presenti.

3. Risultati e progressi concreti

  • Implementata una base kernel modulare con scheduler, VM primitives, syscall dispatcher, interrupt/trap stubs.
  • IPC evoluta con transfer handle, attenuazione diritti, ownership waitset, lifecycle waitset, timed waits.
  • Isolamento handle per PID e policy di duplicazione cross-process (vincoli parent/child + right MANAGE).
  • Process lifecycle introdotto (NEW/RUNNING/BLOCKED/TERMINATED) con terminate cleanup.
  • Test host-side estesi e verdi (ALL_TESTS_PASS con build manuale gcc).

4. Problemi e criticità

ProblemaImpattoGravitàPossibile causa
Assenza validazione stabile CMake/CI end-to-endAffidabilità delivery ridottaAltaToolchain/host eterogenei
Gap tra host simulation e runtime bare-metalRischio regressioni in bring-upAltaMancanza smoke test emulatore integrato
Milestone M6+ lontane dall'evidenza tecnica attualeSlittamento roadmap compatibilitàAltaScope elevato e dipendenze non chiuse
Mancanza KPI temporali formalizzatiForecast deboleMediaTracking centrato su output tecnici

5. Analisi dei rischi

  • Rischi immediati:
    • bring-up UEFI/IRQ/timer reali (probabilità alta, impatto alto, urgenza alta)
    • pipeline CI/build unica non stabilizzata (probabilità media-alta, impatto alto, urgenza alta)
  • Rischi latenti:
    • debito di integrazione tra moduli stub e runtime reale
    • scope creep sulla compatibilità Linux/Windows/macOS
    • allineamento security-first incompleto senza MAC/token propagation completi

6. Efficienza operativa

  • Buona qualità esecutiva: commit incrementali, test-driven su primitive, tracking continuo.
  • Chiarezza responsabilità tecnica elevata per modulo.
  • Inefficienza principale: verifica tecnica dipendente da workaround locali invece di pipeline standard.

7. Azioni correttive raccomandate

Alta priorità

  • Stabilizzare pipeline unica configure/build/test/smoke.
    • Obiettivo: eliminare dipendenze ambiente locale.
    • Impatto atteso: qualità delivery e regressioni più rapide da intercettare.
    • Tempistica: 1 settimana.
  • Chiudere M2/M3 con Definition of Done eseguibile su emulatore.
    • Obiettivo: validare boot reale + timer/interrupt reali.
    • Impatto atteso: riduzione rischio tecnico primario.
    • Tempistica: 1-2 settimane.

Media priorità

  • Introdurre KPI progetto (velocity, test pass rate, defect trend).
    • Obiettivo: forecast affidabile.
    • Impatto atteso: governance migliore.
    • Tempistica: 3-5 giorni.

Miglioramenti strategici

  • Separare backlog Foundation vs Compatibility demos.
    • Obiettivo: evitare competizione tra priorità.
    • Impatto atteso: throughput e prevedibilità migliori.
    • Tempistica: immediata.

8. Prossimi passi suggeriti

  • Finalizzare handle-based process/thread object integration.
  • Definire smoke test su emulatore per M2/M3.
  • Standardizzare CI su toolchain unica e ripetibile.
  • Definire roadmap incrementale demo-driven per M8/M9.
  • Eseguire review rischi quindicinale con aggiornamento milestones.

Risk Register

R1: hybrid kernel boundary erosion

Risk:

The project may keep moving subsystems into kernel space for convenience, undermining isolation and maintainability.

Mitigation:

  • require documented justification for privileged placement
  • benchmark before and after any boundary change
  • review boundary exceptions in architecture reviews

R2: IPC overhead harms the service-oriented model

Risk:

Poor IPC design could make user-space drivers, filesystems, and compatibility services too expensive.

Mitigation:

  • design IPC early as a first-class subsystem
  • measure latency and copy counts from initial bring-up
  • use shared memory and batching for bulk traffic

R3: compatibility scope expands faster than the core OS matures

Risk:

Linux, Windows, and macOS support could become a source of uncontrolled scope growth.

Mitigation:

  • use tiered compatibility commitments
  • prioritize Linux first
  • isolate foreign behavior in runtimes and guest paths rather than in the native kernel ABI

R4: boot and hardware bring-up complexity delays progress

Risk:

Early platform work can absorb too much time before the kernel core becomes testable.

Mitigation:

  • constrain Phase 2 and Phase 3 to x86_64 plus UEFI and QEMU first
  • keep the initial device set intentionally narrow
  • treat custom bootloader maturity as staged rather than all-or-nothing

R5: security model remains aspirational instead of enforced

Risk:

Capability language and sandboxing goals could stay descriptive without becoming concrete implementation rules.

Mitigation:

  • define handle-rights and policy hooks before large user-space expansion
  • add security-focused tests to milestone gates
  • review every new subsystem for privilege boundary assumptions

Storage: from one filesystem to several

Where this starts

There is no filesystem abstraction. kernel/arch/x86_64/arch_hw.c calls vibeos_x86_64_fat_open, fat_read_at, fat_list, fat_write_file and their siblings directly, from twenty places across the syscall layer. In the kernel as it stands, "filesystem" and "FAT" are the same word.

There is also no block cache: every read reaches the device. FAT survives that because its structures are small and mostly sequential. Nothing more complex would.

So the work is not "add filesystems". It is "introduce the layer that makes a second filesystem possible", and then add filesystems as clients of it. The order below is chosen so that each stage is verifiable on its own, and so that the abstraction is designed against two real clients rather than one imagined one.

Stage 1 - a block layer with a cache

A device is a function pair: read this sector into this buffer, write this buffer to this sector. Nothing above it should know it is virtio, and nothing in it should know what the bytes mean.

On top of that, a cache of fixed-size blocks with write-back and an explicit flush. Write-back rather than write-through because the point is to make metadata updates affordable; explicit flush because durability has to be something a filesystem asks for at a moment it chooses, not a side effect.

Portable and host-testable: a fake device backed by an array exercises eviction, dirty write-back, flush ordering and read-after-write without hardware. That matters - kernel/fs/fat_chain.c already showed that moving logic out of the arch file is what let its bug be found by a test sweep rather than by a boot.

Stage 2 - the VFS interface

Two objects, kept deliberately small:

  • a mounted filesystem: given a path, resolve it to a node; list a directory; create and remove
  • a node: read at an offset, write at an offset, report size and kind

Everything the syscall layer needs today - open, read, write, close, getdents64, unlink, mkdir, fstat, newfstatat, execve - is expressed in those terms. The twenty direct calls in arch_hw.c become calls through a mount table.

The interface gets designed here and proven in stage 3, but it is not considered settled until stage 5, when something that is not FAT has to fit into it.

Stage 3 - FAT becomes a client

Port the existing driver behind the interface with no behaviour change. The boot gate is the check: BusyBox reads files, lists directories and execs programs, so if FAT still works through the new layer, the layer carries the existing contract. Nothing new is added here on purpose - a refactor and a feature in the same step means neither is verified.

Stage 4 - partition tables

MBR and GPT. Today the volume is mounted directly, which works only because QEMU hands us a bare FAT image. A real disk has a partition table, and every filesystem after this one needs to be found before it can be mounted.

Stage 5 - ext2, read-only

The first client that is not FAT, and the reason it comes before the easier ones: it has a genuinely different shape - superblock, block groups, inodes, indirect blocks - so it is what tells us whether the stage 2 interface was designed or merely extracted from FAT. Expect to change the interface here. That is the point.

Stage 6 - the cheap ones

ISO9660 and exFAT, in that order. ISO9660 is small and immediately useful: it is what an installation CD carries. exFAT is conceptually FAT with a 32-bit table and an allocation bitmap, and its specification has been published since 2019.

Stage 7 - NTFS, read-only

Feasible and worth doing: everything is a file, including $MFT, and the records have a regular structure. The work is in sector fixups, resident versus non-resident attributes, and run lists. Read-only keeps it bounded.

Stage 8 - durability (roadmap M16)

Ordered metadata updates and a journal, with the exit criterion already written in the roadmap: cutting power during a write must not corrupt the volume. This is the same work as M16, not a separate track, and the block cache from stage 1 is where the ordering is enforced.

What is deliberately not planned

NTFS write. Not because it is uninteresting, but because it is a different size of problem: $LogFile, $Bitmap, an $MFT that grows, compression, ACLs, and formats Microsoft never fully documented. Reliable NTFS writing on Linux arrived after two decades and by way of a driver donated by a company that did it professionally. If a concrete need appears, it gets planned then, on evidence.

How each stage is verified

Stages 1, 5, 6 and 7 are mostly parsing and bookkeeping, so they belong in kernel/fs/ as portable code with host tests - the same arrangement that let fat_chain.c be swept exhaustively against a reference implementation.

Stages 2, 3 and 4 change what the kernel does at runtime, so the boot gate is the check: it already asserts that a real program reads a real file from the volume.

Stage 8 needs a test that does not exist yet: kill the machine mid-write and mount the result. That test is the milestone, more than the journal is.

Status

All eight stages are built. Stage 8 delivered the journal, the device-level barrier it needs, and the power-cut sweep; what it did not deliver is a filesystem that routes its metadata through the journal, so M16's exit criterion is met for the mechanism and not yet for a volume. That connection is the next piece of storage work.

Documentation debt, settled

The list this section used to carry has been worked through: the two roadmap files were deleted, the filesystem and progress documents rewritten around the layer that now exists, and docs/graphics.md and docs/process_semantics.md written for the two areas that had code and no document.

Test Automation Specification

1. Purpose

This document specifies the automated test system for VibeOS with a special requirement: test outputs must be useful both to human engineers and to an implementation agent working directly in the repository.

The automation must therefore provide:

  • deterministic pass or fail results
  • concise failure summaries
  • structured machine-readable artifacts
  • subsystem-specific diagnostics that reduce re-triage effort

2. Test automation goals

  • validate bring-up milestones continuously
  • catch regressions close to the change that caused them
  • provide enough context to support autonomous remediation work
  • keep emulator-based feedback fast enough for iterative kernel development

3. Test layers

LayerPurposeTypical runtime target
L0 host-unitpure logic and parsers on host machineseconds
L1 image-buildvalidates build graph and boot artifact generationminutes
L2 boot-smokevalidates boot to known checkpoints in QEMUminutes
L3 kernel-subsystemvalidates VM, scheduler, interrupts, IPC semanticsminutes
L4 service-integrationvalidates init, service manager, and core servicesminutes
L5 compatibility-regressionvalidates foreign-runtime expectationslonger-running and selective

4. Mandatory automation properties

  • tests must be runnable non-interactively
  • every run must emit both human-readable logs and machine-readable result artifacts
  • failures must include a stable test identifier
  • kernel and emulator failures must capture the last known boot or subsystem phase
  • timeout failures must be distinguished from assertion failures and infrastructure failures

5. Feedback contract for implementation agents

Every automated test run must emit a structured summary file, recommended path:

  • artifacts/test-summary.json

Recommended schema:

{
  "run_id": "string",
  "git_revision": "string",
  "suite": "boot-smoke",
  "status": "pass|fail|infra_error|timeout",
  "duration_ms": 0,
  "failed_tests": [
    {
      "id": "BOOT.SMOKE.SERIAL.001",
      "title": "Kernel did not emit expected boot banner",
      "layer": "L2",
      "subsystem": "boot",
      "severity": "blocker|high|medium|low",
      "symptom": "Expected token BOOT_OK was missing from serial output",
      "likely_causes": [
        "serial init failed",
        "kernel did not reach post-paging stage"
      ],
      "suggested_entrypoints": [
        "boot/",
        "kernel/arch/",
        "kernel/core/"
      ],
      "artifacts": [
        "artifacts/serial.log",
        "artifacts/qemu-exit.txt"
      ]
    }
  ]
}

This contract is designed to let an implementation agent immediately identify:

  • which subsystem probably failed
  • whether the failure is code, environment, or timeout related
  • which files and logs are the best next entrypoints

6. Human-readable output requirements

Alongside structured output, the test system must produce:

  • concise console summary
  • per-suite log
  • serial log capture for emulator runs
  • failure excerpts limited to the most relevant lines first

Recommended console format:

[FAIL] BOOT.SMOKE.SERIAL.001 boot Kernel did not emit expected boot banner
  phase: early_boot
  symptom: token BOOT_OK missing from serial log
  next: inspect artifacts/serial.log and boot serial init path

7. Subsystem-specific diagnostics

7.1 Boot and early kernel

Required artifacts:

  • serial log
  • boot phase markers
  • emulator exit reason
  • panic signature if available

7.2 Memory management

Required artifacts:

  • mapping operation trace
  • last page-fault record
  • allocator counters

7.3 Scheduler

Required artifacts:

  • dispatch trace excerpt
  • timer tick counters
  • runnable-queue snapshot on failure where feasible

7.4 IPC

Required artifacts:

  • channel queue depth counters
  • message send or receive trace excerpt
  • timeout source attribution

8. Test IDs and taxonomy

Test identifiers must be stable and structured:

<SUBSYSTEM>.<SUITE>.<CATEGORY>.<NUMBER>

Examples:

  • BOOT.SMOKE.SERIAL.001
  • MM.VM.MAP.004
  • SCHED.CORE.PREEMPT.002
  • IPC.CHANNEL.RPC.003

9. Result classification

Every failure must map to one of these statuses:

  • assertion_failure
  • timeout
  • panic
  • build_failure
  • infra_error
  • flaky_suspected

Every failure must also include severity guidance:

  • blocker: no further bring-up progress likely
  • high: milestone exit criteria blocked
  • medium: feature behavior wrong but development can continue in parallel
  • low: non-blocking regression or degraded diagnostics

10. Milestone-aligned suite plan

M1 toolchain and build skeleton

Required automation:

  • build graph smoke test
  • artifact existence validation
  • toolchain version capture

M2 boot to kernel banner

Required automation:

  • QEMU boot smoke
  • serial token assertion
  • timeout and hang detection

M3 memory and interrupts online

Required automation:

  • page-table setup smoke
  • page-fault negative test
  • timer interrupt assertion

M4 scheduler and multitasking

Required automation:

  • context-switch smoke
  • timeslice expiry test
  • multi-thread fairness sanity test

M5 first user-space service

Required automation:

  • init launch assertion
  • first IPC handshake test
  • supervised child start smoke

11. Feedback tuning for autonomous development

To maximize usefulness for autonomous remediation, the automation should:

  • attach likely subsystem ownership to every failure
  • suggest the most relevant file roots
  • prefer phase markers over raw logs alone
  • distinguish deterministic failures from infrastructure noise
  • preserve the first failure cause even when cascading errors follow

The goal is not to replace engineering judgment, but to reduce search cost after each failed run.

12. Non-goals

  • full replacement of manual kernel debugging
  • heuristic auto-fixing by the test harness itself
  • hiding raw logs behind summaries

13. Open implementation decisions

  • exact artifact directory layout
  • whether JSON, JSONL, or both are emitted
  • whether the test harness is implemented in Python, CMake scripts, or another host tool
  • how much in-kernel trace data is safe to keep enabled in debug automation builds

Test Feedback Profiles

Purpose

Different development activities need different feedback density. This document defines recommended profiles for automated test output.

Profiles

fast

Use for local edit-compile-run loops.

Characteristics:

  • minimal suite
  • concise console output
  • only top failure artifact references

debug

Use when a regression is under active investigation.

Characteristics:

  • includes trace excerpts
  • captures additional subsystem counters
  • keeps larger serial and emulator logs

agent

Use when the output is intended to guide an implementation agent.

Characteristics:

  • always emits test-summary.json
  • includes likely_causes
  • includes suggested_entrypoints
  • preserves stable test IDs and subsystem mapping
  • classifies invalid CMake generator/configure errors early and falls back to manual host build when possible

ci

Use for repository-level gates.

Characteristics:

  • reproducible suite selection
  • artifact retention policy
  • explicit pass, fail, timeout, and infra-error distinction
  • local human iteration: fast
  • difficult bug investigation: debug
  • autonomous or assisted remediation loop: agent
  • pull request or branch gate: ci